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

122
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,122 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
// Layout
import { Layout } from '@/components/layout/Layout'
// Pages
import { HomePage } from '@/pages/HomePage'
import { LoginPage } from '@/pages/LoginPage'
import { RegisterPage } from '@/pages/RegisterPage'
import { MarathonsPage } from '@/pages/MarathonsPage'
import { CreateMarathonPage } from '@/pages/CreateMarathonPage'
import { MarathonPage } from '@/pages/MarathonPage'
import { LobbyPage } from '@/pages/LobbyPage'
import { PlayPage } from '@/pages/PlayPage'
import { LeaderboardPage } from '@/pages/LeaderboardPage'
// Protected route wrapper
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
// Public route wrapper (redirect if authenticated)
function PublicRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
if (isAuthenticated) {
return <Navigate to="/marathons" replace />
}
return <>{children}</>
}
function App() {
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<HomePage />} />
<Route
path="login"
element={
<PublicRoute>
<LoginPage />
</PublicRoute>
}
/>
<Route
path="register"
element={
<PublicRoute>
<RegisterPage />
</PublicRoute>
}
/>
<Route
path="marathons"
element={
<ProtectedRoute>
<MarathonsPage />
</ProtectedRoute>
}
/>
<Route
path="marathons/create"
element={
<ProtectedRoute>
<CreateMarathonPage />
</ProtectedRoute>
}
/>
<Route
path="marathons/:id"
element={
<ProtectedRoute>
<MarathonPage />
</ProtectedRoute>
}
/>
<Route
path="marathons/:id/lobby"
element={
<ProtectedRoute>
<LobbyPage />
</ProtectedRoute>
}
/>
<Route
path="marathons/:id/play"
element={
<ProtectedRoute>
<PlayPage />
</ProtectedRoute>
}
/>
<Route
path="marathons/:id/leaderboard"
element={
<ProtectedRoute>
<LeaderboardPage />
</ProtectedRoute>
}
/>
</Route>
</Routes>
)
}
export default App

30
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,30 @@
import client from './client'
import type { TokenResponse, User } from '@/types'
export interface RegisterData {
login: string
password: string
nickname: string
}
export interface LoginData {
login: string
password: string
}
export const authApi = {
register: async (data: RegisterData): Promise<TokenResponse> => {
const response = await client.post<TokenResponse>('/auth/register', data)
return response.data
},
login: async (data: LoginData): Promise<TokenResponse> => {
const response = await client.post<TokenResponse>('/auth/login', data)
return response.data
},
me: async (): Promise<User> => {
const response = await client.get<User>('/auth/me')
return response.data
},
}

View File

@@ -0,0 +1,34 @@
import axios, { AxiosError } from 'axios'
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
const client = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor to add auth token
client.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Response interceptor to handle errors
client.interceptors.response.use(
(response) => response,
(error: AxiosError<{ detail: string }>) => {
if (error.response?.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default client

11
frontend/src/api/feed.ts Normal file
View File

@@ -0,0 +1,11 @@
import client from './client'
import type { FeedResponse } from '@/types'
export const feedApi = {
get: async (marathonId: number, limit = 20, offset = 0): Promise<FeedResponse> => {
const response = await client.get<FeedResponse>(`/marathons/${marathonId}/feed`, {
params: { limit, offset },
})
return response.data
},
}

70
frontend/src/api/games.ts Normal file
View File

@@ -0,0 +1,70 @@
import client from './client'
import type { Game, Challenge } from '@/types'
export interface CreateGameData {
title: string
download_url: string
genre?: string
cover_url?: string
}
export interface CreateChallengeData {
title: string
description: string
type: string
difficulty: string
points: number
estimated_time?: number
proof_type: string
proof_hint?: string
}
export const gamesApi = {
list: async (marathonId: number): Promise<Game[]> => {
const response = await client.get<Game[]>(`/marathons/${marathonId}/games`)
return response.data
},
get: async (id: number): Promise<Game> => {
const response = await client.get<Game>(`/games/${id}`)
return response.data
},
create: async (marathonId: number, data: CreateGameData): Promise<Game> => {
const response = await client.post<Game>(`/marathons/${marathonId}/games`, data)
return response.data
},
delete: async (id: number): Promise<void> => {
await client.delete(`/games/${id}`)
},
uploadCover: async (id: number, file: File): Promise<Game> => {
const formData = new FormData()
formData.append('file', file)
const response = await client.post<Game>(`/games/${id}/cover`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
},
// Challenges
getChallenges: async (gameId: number): Promise<Challenge[]> => {
const response = await client.get<Challenge[]>(`/games/${gameId}/challenges`)
return response.data
},
createChallenge: async (gameId: number, data: CreateChallengeData): Promise<Challenge> => {
const response = await client.post<Challenge>(`/games/${gameId}/challenges`, data)
return response.data
},
deleteChallenge: async (id: number): Promise<void> => {
await client.delete(`/challenges/${id}`)
},
generateChallenges: async (marathonId: number): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>(`/marathons/${marathonId}/generate-challenges`)
return response.data
},
}

View File

@@ -0,0 +1,5 @@
export { authApi } from './auth'
export { marathonsApi } from './marathons'
export { gamesApi } from './games'
export { wheelApi } from './wheel'
export { feedApi } from './feed'

View File

@@ -0,0 +1,64 @@
import client from './client'
import type { Marathon, MarathonListItem, LeaderboardEntry, ParticipantInfo, User } from '@/types'
export interface CreateMarathonData {
title: string
description?: string
start_date: string
duration_days?: number
}
export interface ParticipantWithUser extends ParticipantInfo {
user: User
}
export const marathonsApi = {
list: async (): Promise<MarathonListItem[]> => {
const response = await client.get<MarathonListItem[]>('/marathons')
return response.data
},
get: async (id: number): Promise<Marathon> => {
const response = await client.get<Marathon>(`/marathons/${id}`)
return response.data
},
create: async (data: CreateMarathonData): Promise<Marathon> => {
const response = await client.post<Marathon>('/marathons', data)
return response.data
},
update: async (id: number, data: Partial<CreateMarathonData>): Promise<Marathon> => {
const response = await client.patch<Marathon>(`/marathons/${id}`, data)
return response.data
},
delete: async (id: number): Promise<void> => {
await client.delete(`/marathons/${id}`)
},
start: async (id: number): Promise<Marathon> => {
const response = await client.post<Marathon>(`/marathons/${id}/start`)
return response.data
},
finish: async (id: number): Promise<Marathon> => {
const response = await client.post<Marathon>(`/marathons/${id}/finish`)
return response.data
},
join: async (inviteCode: string): Promise<Marathon> => {
const response = await client.post<Marathon>('/marathons/join', { invite_code: inviteCode })
return response.data
},
getParticipants: async (id: number): Promise<ParticipantWithUser[]> => {
const response = await client.get<ParticipantWithUser[]>(`/marathons/${id}/participants`)
return response.data
},
getLeaderboard: async (id: number): Promise<LeaderboardEntry[]> => {
const response = await client.get<LeaderboardEntry[]>(`/marathons/${id}/leaderboard`)
return response.data
},
}

41
frontend/src/api/wheel.ts Normal file
View File

@@ -0,0 +1,41 @@
import client from './client'
import type { SpinResult, Assignment, CompleteResult, DropResult } from '@/types'
export const wheelApi = {
spin: async (marathonId: number): Promise<SpinResult> => {
const response = await client.post<SpinResult>(`/marathons/${marathonId}/spin`)
return response.data
},
getCurrentAssignment: async (marathonId: number): Promise<Assignment | null> => {
const response = await client.get<Assignment | null>(`/marathons/${marathonId}/current-assignment`)
return response.data
},
complete: async (
assignmentId: number,
data: { proof_url?: string; comment?: string; proof_file?: File }
): Promise<CompleteResult> => {
const formData = new FormData()
if (data.proof_url) formData.append('proof_url', data.proof_url)
if (data.comment) formData.append('comment', data.comment)
if (data.proof_file) formData.append('proof_file', data.proof_file)
const response = await client.post<CompleteResult>(`/assignments/${assignmentId}/complete`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
},
drop: async (assignmentId: number): Promise<DropResult> => {
const response = await client.post<DropResult>(`/assignments/${assignmentId}/drop`)
return response.data
},
getHistory: async (marathonId: number, limit = 20, offset = 0): Promise<Assignment[]> => {
const response = await client.get<Assignment[]>(`/marathons/${marathonId}/my-history`, {
params: { limit, offset },
})
return response.data
},
}

View File

@@ -0,0 +1,77 @@
import { Outlet, Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { Gamepad2, LogOut, Trophy, User } from 'lucide-react'
export function Layout() {
const { user, isAuthenticated, logout } = useAuthStore()
const navigate = useNavigate()
const handleLogout = () => {
logout()
navigate('/login')
}
return (
<div className="min-h-screen flex flex-col">
{/* Header */}
<header className="bg-gray-800 border-b border-gray-700">
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 text-xl font-bold text-white">
<Gamepad2 className="w-8 h-8 text-primary-500" />
<span>Игровой Марафон</span>
</Link>
<nav className="flex items-center gap-4">
{isAuthenticated ? (
<>
<Link
to="/marathons"
className="flex items-center gap-2 text-gray-300 hover:text-white transition-colors"
>
<Trophy className="w-5 h-5" />
<span>Марафоны</span>
</Link>
<div className="flex items-center gap-3 ml-4 pl-4 border-l border-gray-700">
<div className="flex items-center gap-2 text-gray-300">
<User className="w-5 h-5" />
<span>{user?.nickname}</span>
</div>
<button
onClick={handleLogout}
className="p-2 text-gray-400 hover:text-white transition-colors"
title="Выйти"
>
<LogOut className="w-5 h-5" />
</button>
</div>
</>
) : (
<>
<Link to="/login" className="text-gray-300 hover:text-white transition-colors">
Войти
</Link>
<Link to="/register" className="btn btn-primary">
Регистрация
</Link>
</>
)}
</nav>
</div>
</header>
{/* Main content */}
<main className="flex-1 container mx-auto px-4 py-8">
<Outlet />
</main>
{/* Footer */}
<footer className="bg-gray-800 border-t border-gray-700 py-4">
<div className="container mx-auto px-4 text-center text-gray-500 text-sm">
Игровой Марафон &copy; {new Date().getFullYear()}
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { forwardRef, type ButtonHTMLAttributes } from 'react'
import { clsx } from 'clsx'
import { Loader2 } from 'lucide-react'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
size?: 'sm' | 'md' | 'lg'
isLoading?: boolean
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'primary', size = 'md', isLoading, children, disabled, ...props }, ref) => {
return (
<button
ref={ref}
disabled={disabled || isLoading}
className={clsx(
'inline-flex items-center justify-center font-medium rounded-lg transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed',
{
'bg-primary-600 hover:bg-primary-700 text-white': variant === 'primary',
'bg-gray-700 hover:bg-gray-600 text-white': variant === 'secondary',
'bg-red-600 hover:bg-red-700 text-white': variant === 'danger',
'bg-transparent hover:bg-gray-800 text-gray-300': variant === 'ghost',
'px-3 py-1.5 text-sm': size === 'sm',
'px-4 py-2 text-base': size === 'md',
'px-6 py-3 text-lg': size === 'lg',
},
className
)}
{...props}
>
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{children}
</button>
)
}
)
Button.displayName = 'Button'

View File

@@ -0,0 +1,54 @@
import { type ReactNode } from 'react'
import { clsx } from 'clsx'
interface CardProps {
children: ReactNode
className?: string
}
export function Card({ children, className }: CardProps) {
return (
<div className={clsx('bg-gray-800 rounded-xl p-6 shadow-lg', className)}>
{children}
</div>
)
}
interface CardHeaderProps {
children: ReactNode
className?: string
}
export function CardHeader({ children, className }: CardHeaderProps) {
return (
<div className={clsx('mb-4', className)}>
{children}
</div>
)
}
interface CardTitleProps {
children: ReactNode
className?: string
}
export function CardTitle({ children, className }: CardTitleProps) {
return (
<h3 className={clsx('text-xl font-bold text-white', className)}>
{children}
</h3>
)
}
interface CardContentProps {
children: ReactNode
className?: string
}
export function CardContent({ children, className }: CardContentProps) {
return (
<div className={clsx('text-gray-300', className)}>
{children}
</div>
)
}

View File

@@ -0,0 +1,36 @@
import { forwardRef, type InputHTMLAttributes } from 'react'
import { clsx } from 'clsx'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, id, ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label htmlFor={id} className="block text-sm font-medium text-gray-300 mb-1">
{label}
</label>
)}
<input
ref={ref}
id={id}
className={clsx(
'w-full px-4 py-2 bg-gray-800 border rounded-lg text-white placeholder-gray-500',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
'transition-colors',
error ? 'border-red-500' : 'border-gray-700',
className
)}
{...props}
/>
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
</div>
)
}
)
Input.displayName = 'Input'

View File

@@ -0,0 +1,3 @@
export { Button } from './Button'
export { Input } from './Input'
export { Card, CardHeader, CardTitle, CardContent } from './Card'

37
frontend/src/index.css Normal file
View File

@@ -0,0 +1,37 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-gray-900 text-gray-100 min-h-screen;
}
@layer components {
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply bg-primary-600 hover:bg-primary-700 text-white;
}
.btn-secondary {
@apply bg-gray-700 hover:bg-gray-600 text-white;
}
.btn-danger {
@apply bg-red-600 hover:bg-red-700 text-white;
}
.input {
@apply w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
}
.card {
@apply bg-gray-800 rounded-xl p-6 shadow-lg;
}
.link {
@apply text-primary-400 hover:text-primary-300 transition-colors;
}
}

13
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

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'

View File

@@ -0,0 +1,90 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { User } from '@/types'
import { authApi, type RegisterData, type LoginData } from '@/api/auth'
interface AuthState {
user: User | null
token: string | null
isAuthenticated: boolean
isLoading: boolean
error: string | null
login: (data: LoginData) => Promise<void>
register: (data: RegisterData) => Promise<void>
logout: () => void
clearError: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
error: null,
login: async (data) => {
set({ isLoading: true, error: null })
try {
const response = await authApi.login(data)
localStorage.setItem('token', response.access_token)
set({
user: response.user,
token: response.access_token,
isAuthenticated: true,
isLoading: false,
})
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
set({
error: error.response?.data?.detail || 'Login failed',
isLoading: false,
})
throw err
}
},
register: async (data) => {
set({ isLoading: true, error: null })
try {
const response = await authApi.register(data)
localStorage.setItem('token', response.access_token)
set({
user: response.user,
token: response.access_token,
isAuthenticated: true,
isLoading: false,
})
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
set({
error: error.response?.data?.detail || 'Registration failed',
isLoading: false,
})
throw err
}
},
logout: () => {
localStorage.removeItem('token')
set({
user: null,
token: null,
isAuthenticated: false,
})
},
clearError: () => set({ error: null }),
}),
{
name: 'auth-storage',
partialize: (state) => ({
user: state.user,
token: state.token,
isAuthenticated: state.isAuthenticated,
}),
}
)
)

158
frontend/src/types/index.ts Normal file
View File

@@ -0,0 +1,158 @@
// User types
export interface User {
id: number
login: string
nickname: string
avatar_url: string | null
created_at: string
}
export interface TokenResponse {
access_token: string
token_type: string
user: User
}
// Marathon types
export type MarathonStatus = 'preparing' | 'active' | 'finished'
export interface ParticipantInfo {
id: number
total_points: number
current_streak: number
drop_count: number
joined_at: string
}
export interface Marathon {
id: number
title: string
description: string | null
organizer: User
status: MarathonStatus
invite_code: string
start_date: string | null
end_date: string | null
participants_count: number
games_count: number
created_at: string
my_participation: ParticipantInfo | null
}
export interface MarathonListItem {
id: number
title: string
status: MarathonStatus
participants_count: number
start_date: string | null
end_date: string | null
}
export interface LeaderboardEntry {
rank: number
user: User
total_points: number
current_streak: number
completed_count: number
dropped_count: number
}
// Game types
export interface Game {
id: number
title: string
cover_url: string | null
download_url: string
genre: string | null
added_by: User | null
challenges_count: number
created_at: string
}
export interface GameShort {
id: number
title: string
cover_url: string | null
}
// Challenge types
export type ChallengeType =
| 'completion'
| 'no_death'
| 'speedrun'
| 'collection'
| 'achievement'
| 'challenge_run'
| 'score_attack'
| 'time_trial'
export type Difficulty = 'easy' | 'medium' | 'hard'
export type ProofType = 'screenshot' | 'video' | 'steam'
export interface Challenge {
id: number
game: GameShort
title: string
description: string
type: ChallengeType
difficulty: Difficulty
points: number
estimated_time: number | null
proof_type: ProofType
proof_hint: string | null
is_generated: boolean
created_at: string
}
// Assignment types
export type AssignmentStatus = 'active' | 'completed' | 'dropped'
export interface Assignment {
id: number
challenge: Challenge
status: AssignmentStatus
proof_url: string | null
proof_comment: string | null
points_earned: number
streak_at_completion: number | null
started_at: string
completed_at: string | null
}
export interface SpinResult {
assignment_id: number
game: Game
challenge: Challenge
can_drop: boolean
drop_penalty: number
}
export interface CompleteResult {
points_earned: number
streak_bonus: number
total_points: number
new_streak: number
}
export interface DropResult {
penalty: number
total_points: number
new_drop_count: number
}
// Activity types
export type ActivityType = 'join' | 'spin' | 'complete' | 'drop' | 'start_marathon' | 'finish_marathon'
export interface Activity {
id: number
type: ActivityType
user: User
data: Record<string, unknown> | null
created_at: string
}
export interface FeedResponse {
items: Activity[]
total: number
has_more: boolean
}

9
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}