initial
This commit is contained in:
30
frontend/Dockerfile
Normal file
30
frontend/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
||||
FROM node:20-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build argument for API URL
|
||||
ARG VITE_API_URL=/api/v1
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
|
||||
# Build
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built files
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Игровой Марафон</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
17
frontend/nginx.conf
Normal file
17
frontend/nginx.conf
Normal file
@@ -0,0 +1,17 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
42
frontend/package.json
Normal file
42
frontend/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "game-marathon-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"axios": "^1.6.2",
|
||||
"zustand": "^4.4.7",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"zod": "^3.22.4",
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"framer-motion": "^10.16.16",
|
||||
"date-fns": "^3.0.6",
|
||||
"lucide-react": "^0.303.0",
|
||||
"clsx": "^2.0.0",
|
||||
"tailwind-merge": "^2.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@typescript-eslint/eslint-plugin": "^6.15.0",
|
||||
"@typescript-eslint/parser": "^6.15.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.10"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
122
frontend/src/App.tsx
Normal file
122
frontend/src/App.tsx
Normal 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
30
frontend/src/api/auth.ts
Normal 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
|
||||
},
|
||||
}
|
||||
34
frontend/src/api/client.ts
Normal file
34
frontend/src/api/client.ts
Normal 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
11
frontend/src/api/feed.ts
Normal 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
70
frontend/src/api/games.ts
Normal 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
|
||||
},
|
||||
}
|
||||
5
frontend/src/api/index.ts
Normal file
5
frontend/src/api/index.ts
Normal 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'
|
||||
64
frontend/src/api/marathons.ts
Normal file
64
frontend/src/api/marathons.ts
Normal 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
41
frontend/src/api/wheel.ts
Normal 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
|
||||
},
|
||||
}
|
||||
77
frontend/src/components/layout/Layout.tsx
Normal file
77
frontend/src/components/layout/Layout.tsx
Normal 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">
|
||||
Игровой Марафон © {new Date().getFullYear()}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
frontend/src/components/ui/Button.tsx
Normal file
40
frontend/src/components/ui/Button.tsx
Normal 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'
|
||||
54
frontend/src/components/ui/Card.tsx
Normal file
54
frontend/src/components/ui/Card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
frontend/src/components/ui/Input.tsx
Normal file
36
frontend/src/components/ui/Input.tsx
Normal 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'
|
||||
3
frontend/src/components/ui/index.ts
Normal file
3
frontend/src/components/ui/index.ts
Normal 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
37
frontend/src/index.css
Normal 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
13
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
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'
|
||||
90
frontend/src/store/auth.ts
Normal file
90
frontend/src/store/auth.ts
Normal 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
158
frontend/src/types/index.ts
Normal 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
9
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
47
frontend/tailwind.config.js
Normal file
47
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e',
|
||||
950: '#082f49',
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'spin-slow': 'spin 3s linear infinite',
|
||||
'wheel-spin': 'wheel-spin 4s cubic-bezier(0.17, 0.67, 0.12, 0.99) forwards',
|
||||
'fade-in': 'fade-in 0.3s ease-out',
|
||||
'slide-up': 'slide-up 0.3s ease-out',
|
||||
},
|
||||
keyframes: {
|
||||
'wheel-spin': {
|
||||
'0%': { transform: 'rotate(0deg)' },
|
||||
'100%': { transform: 'rotate(var(--wheel-rotation, 1800deg))' },
|
||||
},
|
||||
'fade-in': {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
'slide-up': {
|
||||
'0%': { opacity: '0', transform: 'translateY(10px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
29
frontend/vite.config.ts
Normal file
29
frontend/vite.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user