Улучшение системы оспариваний и исправления
- Оспаривания теперь требуют решения админа после 24ч голосования - Можно повторно оспаривать после разрешённых споров - Исправлены бонусные очки при перепрохождении после оспаривания - Сброс серии при невалидном пруфе - Колесо показывает только доступные игры - Rate limiting только через backend (RATE_LIMIT_ENABLED)
This commit is contained in:
@@ -55,6 +55,15 @@ export function PlayPage() {
|
||||
|
||||
const [returnedAssignments, setReturnedAssignments] = useState<ReturnedAssignment[]>([])
|
||||
|
||||
// Bonus challenge completion
|
||||
const [expandedBonusId, setExpandedBonusId] = useState<number | null>(null)
|
||||
const [bonusProofFile, setBonusProofFile] = useState<File | null>(null)
|
||||
const [bonusProofUrl, setBonusProofUrl] = useState('')
|
||||
const [bonusComment, setBonusComment] = useState('')
|
||||
const [isCompletingBonus, setIsCompletingBonus] = useState(false)
|
||||
|
||||
const bonusFileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const eventFileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -168,17 +177,17 @@ export function PlayPage() {
|
||||
const loadData = async () => {
|
||||
if (!id) return
|
||||
try {
|
||||
const [marathonData, assignment, gamesData, eventData, eventAssignmentData, returnedData] = await Promise.all([
|
||||
const [marathonData, assignment, availableGamesData, eventData, eventAssignmentData, returnedData] = await Promise.all([
|
||||
marathonsApi.get(parseInt(id)),
|
||||
wheelApi.getCurrentAssignment(parseInt(id)),
|
||||
gamesApi.list(parseInt(id), 'approved'),
|
||||
gamesApi.getAvailableGames(parseInt(id)),
|
||||
eventsApi.getActive(parseInt(id)),
|
||||
eventsApi.getEventAssignment(parseInt(id)),
|
||||
assignmentsApi.getReturnedAssignments(parseInt(id)),
|
||||
])
|
||||
setMarathon(marathonData)
|
||||
setCurrentAssignment(assignment)
|
||||
setGames(gamesData)
|
||||
setGames(availableGamesData)
|
||||
setActiveEvent(eventData)
|
||||
setEventAssignment(eventAssignmentData)
|
||||
setReturnedAssignments(returnedData)
|
||||
@@ -219,9 +228,19 @@ export function PlayPage() {
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!currentAssignment) return
|
||||
if (!proofFile && !proofUrl) {
|
||||
toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
|
||||
return
|
||||
|
||||
// For playthrough: allow file, URL, or comment
|
||||
// For challenges: require file or URL
|
||||
if (currentAssignment.is_playthrough) {
|
||||
if (!proofFile && !proofUrl && !comment) {
|
||||
toast.warning('Пожалуйста, предоставьте доказательство (файл, ссылку или комментарий)')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (!proofFile && !proofUrl) {
|
||||
toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setIsCompleting(true)
|
||||
@@ -270,6 +289,39 @@ export function PlayPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleBonusComplete = async (bonusId: number) => {
|
||||
if (!currentAssignment) return
|
||||
if (!bonusProofFile && !bonusProofUrl && !bonusComment) {
|
||||
toast.warning('Прикрепите файл, ссылку или комментарий')
|
||||
return
|
||||
}
|
||||
|
||||
setIsCompletingBonus(true)
|
||||
try {
|
||||
const result = await assignmentsApi.completeBonusAssignment(
|
||||
currentAssignment.id,
|
||||
bonusId,
|
||||
{
|
||||
proof_file: bonusProofFile || undefined,
|
||||
proof_url: bonusProofUrl || undefined,
|
||||
comment: bonusComment || undefined,
|
||||
}
|
||||
)
|
||||
toast.success(`Бонус отмечен! +${result.points_earned} очков начислится при завершении игры`)
|
||||
setBonusProofFile(null)
|
||||
setBonusProofUrl('')
|
||||
setBonusComment('')
|
||||
setExpandedBonusId(null)
|
||||
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
|
||||
await loadData()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(error.response?.data?.detail || 'Не удалось выполнить бонус')
|
||||
} finally {
|
||||
setIsCompletingBonus(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEventComplete = async () => {
|
||||
if (!eventAssignment?.assignment) return
|
||||
if (!eventProofFile && !eventProofUrl) {
|
||||
@@ -529,12 +581,23 @@ export function PlayPage() {
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-white font-medium">{ra.challenge.title}</p>
|
||||
<p className="text-gray-400 text-sm">{ra.challenge.game.title}</p>
|
||||
{ra.is_playthrough ? (
|
||||
<>
|
||||
<p className="text-white font-medium">Прохождение: {ra.game_title}</p>
|
||||
<p className="text-gray-400 text-sm">Прохождение игры</p>
|
||||
</>
|
||||
) : ra.challenge ? (
|
||||
<>
|
||||
<p className="text-white font-medium">{ra.challenge.title}</p>
|
||||
<p className="text-gray-400 text-sm">{ra.challenge.game.title}</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded-lg border border-green-500/30">
|
||||
+{ra.challenge.points}
|
||||
</span>
|
||||
{!ra.is_playthrough && ra.challenge && (
|
||||
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded-lg border border-green-500/30">
|
||||
+{ra.challenge.points}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-orange-300 text-xs mt-2">
|
||||
Причина: {ra.dispute_reason}
|
||||
@@ -640,28 +703,28 @@ export function PlayPage() {
|
||||
<div className="mb-4">
|
||||
<p className="text-gray-400 text-sm mb-1">Игра</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{eventAssignment.assignment.challenge.game.title}
|
||||
{eventAssignment.assignment.challenge?.game.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-gray-400 text-sm mb-1">Задание</p>
|
||||
<p className="text-xl font-bold text-neon-400 mb-2">
|
||||
{eventAssignment.assignment.challenge.title}
|
||||
{eventAssignment.assignment.challenge?.title}
|
||||
</p>
|
||||
<p className="text-gray-300">
|
||||
{eventAssignment.assignment.challenge.description}
|
||||
{eventAssignment.assignment.challenge?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6 flex-wrap">
|
||||
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30">
|
||||
+{eventAssignment.assignment.challenge.points} очков
|
||||
+{eventAssignment.assignment.challenge?.points} очков
|
||||
</span>
|
||||
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
|
||||
{eventAssignment.assignment.challenge.difficulty}
|
||||
{eventAssignment.assignment.challenge?.difficulty}
|
||||
</span>
|
||||
{eventAssignment.assignment.challenge.estimated_time && (
|
||||
{eventAssignment.assignment.challenge?.estimated_time && (
|
||||
<span className="text-gray-400 text-sm flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
~{eventAssignment.assignment.challenge.estimated_time} мин
|
||||
@@ -669,7 +732,7 @@ export function PlayPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{eventAssignment.assignment.challenge.proof_hint && (
|
||||
{eventAssignment.assignment.challenge?.proof_hint && (
|
||||
<div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||
<p className="text-sm text-gray-400">
|
||||
<strong className="text-white">Нужно доказательство:</strong> {eventAssignment.assignment.challenge.proof_hint}
|
||||
@@ -680,7 +743,7 @@ export function PlayPage() {
|
||||
<div className="space-y-4 mb-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Загрузить доказательство ({eventAssignment.assignment.challenge.proof_type})
|
||||
Загрузить доказательство ({eventAssignment.assignment.challenge?.proof_type})
|
||||
</label>
|
||||
|
||||
<input
|
||||
@@ -891,55 +954,248 @@ export function PlayPage() {
|
||||
<>
|
||||
<GlassCard variant="neon">
|
||||
<div className="text-center mb-6">
|
||||
<span className="px-4 py-1.5 bg-neon-500/20 text-neon-400 rounded-full text-sm font-medium border border-neon-500/30">
|
||||
Активное задание
|
||||
<span className={`px-4 py-1.5 rounded-full text-sm font-medium border ${
|
||||
currentAssignment.is_playthrough
|
||||
? 'bg-accent-500/20 text-accent-400 border-accent-500/30'
|
||||
: 'bg-neon-500/20 text-neon-400 border-neon-500/30'
|
||||
}`}>
|
||||
{currentAssignment.is_playthrough ? 'Прохождение игры' : 'Активное задание'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-gray-400 text-sm mb-1">Игра</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{currentAssignment.challenge.game.title}
|
||||
{currentAssignment.is_playthrough
|
||||
? currentAssignment.game?.title
|
||||
: currentAssignment.challenge?.game.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-gray-400 text-sm mb-1">Задание</p>
|
||||
<p className="text-xl font-bold text-neon-400 mb-2">
|
||||
{currentAssignment.challenge.title}
|
||||
</p>
|
||||
<p className="text-gray-300">
|
||||
{currentAssignment.challenge.description}
|
||||
</p>
|
||||
</div>
|
||||
{currentAssignment.is_playthrough ? (
|
||||
// Playthrough task
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<p className="text-gray-400 text-sm mb-1">Задача</p>
|
||||
<p className="text-xl font-bold text-accent-400 mb-2">
|
||||
Пройти игру
|
||||
</p>
|
||||
<p className="text-gray-300">
|
||||
{currentAssignment.playthrough_info?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6 flex-wrap">
|
||||
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30">
|
||||
+{currentAssignment.challenge.points} очков
|
||||
</span>
|
||||
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
|
||||
{currentAssignment.challenge.difficulty}
|
||||
</span>
|
||||
{currentAssignment.challenge.estimated_time && (
|
||||
<span className="text-gray-400 text-sm flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
~{currentAssignment.challenge.estimated_time} мин
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-6 flex-wrap">
|
||||
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30">
|
||||
+{currentAssignment.playthrough_info?.points} очков
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{currentAssignment.challenge.proof_hint && (
|
||||
<div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||
<p className="text-sm text-gray-400">
|
||||
<strong className="text-white">Нужно доказательство:</strong> {currentAssignment.challenge.proof_hint}
|
||||
</p>
|
||||
</div>
|
||||
{currentAssignment.playthrough_info?.proof_hint && (
|
||||
<div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||
<p className="text-sm text-gray-400">
|
||||
<strong className="text-white">Нужно доказательство:</strong> {currentAssignment.playthrough_info.proof_hint}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bonus challenges */}
|
||||
{currentAssignment.bonus_challenges && currentAssignment.bonus_challenges.length > 0 && (
|
||||
<div className="mb-6 p-4 bg-accent-500/10 rounded-xl border border-accent-500/20">
|
||||
<p className="text-accent-400 font-medium mb-3">
|
||||
Бонусные челленджи (опционально) — {currentAssignment.bonus_challenges.filter(b => b.status === 'completed').length}/{currentAssignment.bonus_challenges.length}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{currentAssignment.bonus_challenges.map((bonus) => (
|
||||
<div
|
||||
key={bonus.id}
|
||||
className={`rounded-lg border overflow-hidden ${
|
||||
bonus.status === 'completed'
|
||||
? 'bg-green-500/10 border-green-500/30'
|
||||
: 'bg-dark-700/50 border-dark-600'
|
||||
}`}
|
||||
>
|
||||
{/* Bonus header */}
|
||||
<div
|
||||
className={`p-3 flex items-center justify-between ${
|
||||
bonus.status === 'pending' ? 'cursor-pointer hover:bg-dark-600/50' : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (bonus.status === 'pending') {
|
||||
setExpandedBonusId(expandedBonusId === bonus.id ? null : bonus.id)
|
||||
setBonusProofFile(null)
|
||||
setBonusProofUrl('')
|
||||
setBonusComment('')
|
||||
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{bonus.status === 'completed' && (
|
||||
<Check className="w-4 h-4 text-green-400" />
|
||||
)}
|
||||
<p className="text-white font-medium text-sm">{bonus.challenge.title}</p>
|
||||
</div>
|
||||
<p className="text-gray-400 text-xs mt-0.5">{bonus.challenge.description}</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0 ml-2">
|
||||
{bonus.status === 'completed' ? (
|
||||
<span className="text-green-400 text-sm font-medium">+{bonus.points_earned}</span>
|
||||
) : (
|
||||
<span className="text-accent-400 text-sm">+{bonus.challenge.points}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded form for completing */}
|
||||
{expandedBonusId === bonus.id && bonus.status === 'pending' && (
|
||||
<div className="p-3 border-t border-dark-600 bg-dark-800/50 space-y-3">
|
||||
{bonus.challenge.proof_hint && (
|
||||
<p className="text-xs text-gray-400">
|
||||
<strong className="text-white">Пруф:</strong> {bonus.challenge.proof_hint}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* File upload */}
|
||||
<input
|
||||
ref={bonusFileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
validateAndSetFile(e.target.files?.[0] || null, setBonusProofFile, bonusFileInputRef)
|
||||
}}
|
||||
/>
|
||||
{bonusProofFile ? (
|
||||
<div className="flex items-center gap-2 p-2 bg-dark-700/50 rounded-lg border border-dark-600">
|
||||
<span className="text-white text-sm flex-1 truncate">{bonusProofFile.name}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setBonusProofFile(null)
|
||||
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
|
||||
}}
|
||||
className="p-1 rounded text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
bonusFileInputRef.current?.click()
|
||||
}}
|
||||
className="w-full p-2 border border-dashed border-dark-500 rounded-lg text-gray-400 text-sm hover:border-accent-400 hover:text-accent-400 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Загрузить файл
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="text-center text-gray-500 text-xs">или</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
className="input text-sm"
|
||||
placeholder="Ссылка на пруф (YouTube, Steam и т.д.)"
|
||||
value={bonusProofUrl}
|
||||
onChange={(e) => setBonusProofUrl(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<textarea
|
||||
className="input text-sm resize-none"
|
||||
placeholder="Комментарий (необязательно)"
|
||||
rows={2}
|
||||
value={bonusComment}
|
||||
onChange={(e) => setBonusComment(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<NeonButton
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleBonusComplete(bonus.id)
|
||||
}}
|
||||
isLoading={isCompletingBonus}
|
||||
disabled={!bonusProofFile && !bonusProofUrl && !bonusComment}
|
||||
icon={<Check className="w-3 h-3" />}
|
||||
>
|
||||
Выполнено
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setBonusProofFile(null)
|
||||
setBonusProofUrl('')
|
||||
setBonusComment('')
|
||||
setExpandedBonusId(null)
|
||||
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Нажмите на бонус, чтобы отметить. Очки начислятся при завершении игры.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Regular challenge
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<p className="text-gray-400 text-sm mb-1">Задание</p>
|
||||
<p className="text-xl font-bold text-neon-400 mb-2">
|
||||
{currentAssignment.challenge?.title}
|
||||
</p>
|
||||
<p className="text-gray-300">
|
||||
{currentAssignment.challenge?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6 flex-wrap">
|
||||
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30">
|
||||
+{currentAssignment.challenge?.points} очков
|
||||
</span>
|
||||
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
|
||||
{currentAssignment.challenge?.difficulty}
|
||||
</span>
|
||||
{currentAssignment.challenge?.estimated_time && (
|
||||
<span className="text-gray-400 text-sm flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
~{currentAssignment.challenge.estimated_time} мин
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentAssignment.challenge?.proof_hint && (
|
||||
<div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||
<p className="text-sm text-gray-400">
|
||||
<strong className="text-white">Нужно доказательство:</strong> {currentAssignment.challenge.proof_hint}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Загрузить доказательство ({currentAssignment.challenge.proof_type})
|
||||
Загрузить доказательство ({currentAssignment.is_playthrough
|
||||
? currentAssignment.playthrough_info?.proof_type
|
||||
: currentAssignment.challenge?.proof_type})
|
||||
</label>
|
||||
|
||||
<input
|
||||
@@ -1000,7 +1256,10 @@ export function PlayPage() {
|
||||
className="flex-1"
|
||||
onClick={handleComplete}
|
||||
isLoading={isCompleting}
|
||||
disabled={!proofFile && !proofUrl}
|
||||
disabled={currentAssignment.is_playthrough
|
||||
? (!proofFile && !proofUrl && !comment)
|
||||
: (!proofFile && !proofUrl)
|
||||
}
|
||||
icon={<Check className="w-4 h-4" />}
|
||||
>
|
||||
Выполнено
|
||||
|
||||
Reference in New Issue
Block a user