Files
game-marathon/frontend/src/pages/PlayPage.tsx
2025-12-14 02:42:32 +07:00

316 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}