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

View File

@@ -0,0 +1,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>
)
}