a
This commit is contained in:
@@ -67,18 +67,27 @@ export const assignmentsApi = {
|
||||
completeBonusAssignment: async (
|
||||
assignmentId: number,
|
||||
bonusId: number,
|
||||
data: { proof_file?: File; proof_url?: string; comment?: string }
|
||||
data: { proof_file?: File; proof_files?: File[]; proof_url?: string; comment?: string }
|
||||
): Promise<BonusCompleteResult> => {
|
||||
const formData = new FormData()
|
||||
|
||||
// Support both single file (legacy) and multiple files
|
||||
if (data.proof_file) {
|
||||
formData.append('proof_file', data.proof_file)
|
||||
}
|
||||
if (data.proof_files && data.proof_files.length > 0) {
|
||||
data.proof_files.forEach(file => {
|
||||
formData.append('proof_files', file)
|
||||
})
|
||||
}
|
||||
|
||||
if (data.proof_url) {
|
||||
formData.append('proof_url', data.proof_url)
|
||||
}
|
||||
if (data.comment) {
|
||||
formData.append('comment', data.comment)
|
||||
}
|
||||
|
||||
const response = await client.post<BonusCompleteResult>(
|
||||
`/assignments/${assignmentId}/bonus/${bonusId}/complete`,
|
||||
formData,
|
||||
@@ -103,4 +112,39 @@ export const assignmentsApi = {
|
||||
type: isVideo ? 'video' : 'image',
|
||||
}
|
||||
},
|
||||
|
||||
// Get individual proof file media as blob URL (for multiple proofs support)
|
||||
getProofFileMediaUrl: async (
|
||||
assignmentId: number,
|
||||
proofFileId: number
|
||||
): Promise<{ url: string; type: 'image' | 'video' }> => {
|
||||
const response = await client.get(
|
||||
`/assignments/${assignmentId}/proof-files/${proofFileId}/media`,
|
||||
{ responseType: 'blob' }
|
||||
)
|
||||
const contentType = response.headers['content-type'] || ''
|
||||
const isVideo = contentType.startsWith('video/')
|
||||
return {
|
||||
url: URL.createObjectURL(response.data),
|
||||
type: isVideo ? 'video' : 'image',
|
||||
}
|
||||
},
|
||||
|
||||
// Get individual bonus proof file media as blob URL (for multiple proofs support)
|
||||
getBonusProofFileMediaUrl: async (
|
||||
assignmentId: number,
|
||||
bonusId: number,
|
||||
proofFileId: number
|
||||
): Promise<{ url: string; type: 'image' | 'video' }> => {
|
||||
const response = await client.get(
|
||||
`/assignments/${assignmentId}/bonus/${bonusId}/proof-files/${proofFileId}/media`,
|
||||
{ responseType: 'blob' }
|
||||
)
|
||||
const contentType = response.headers['content-type'] || ''
|
||||
const isVideo = contentType.startsWith('video/')
|
||||
return {
|
||||
url: URL.createObjectURL(response.data),
|
||||
type: isVideo ? 'video' : 'image',
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -14,12 +14,19 @@ export const wheelApi = {
|
||||
|
||||
complete: async (
|
||||
assignmentId: number,
|
||||
data: { proof_url?: string; comment?: string; proof_file?: File }
|
||||
data: { proof_url?: string; comment?: string; proof_file?: File; proof_files?: 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)
|
||||
|
||||
// Support both single file (legacy) and multiple files
|
||||
if (data.proof_file) formData.append('proof_file', data.proof_file)
|
||||
if (data.proof_files && data.proof_files.length > 0) {
|
||||
data.proof_files.forEach(file => {
|
||||
formData.append('proof_files', file)
|
||||
})
|
||||
}
|
||||
|
||||
const response = await client.post<CompleteResult>(`/assignments/${assignmentId}/complete`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useToast } from '@/store/toast'
|
||||
import {
|
||||
ArrowLeft, Loader2, ExternalLink, Image, MessageSquare,
|
||||
ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle,
|
||||
Send, Flag, Gamepad2, Zap, Trophy
|
||||
Send, Flag, Gamepad2, Zap, Trophy, Download, ChevronLeft, ChevronRight, X
|
||||
} from 'lucide-react'
|
||||
|
||||
export function AssignmentDetailPage() {
|
||||
@@ -23,9 +23,20 @@ export function AssignmentDetailPage() {
|
||||
const [proofMediaBlobUrl, setProofMediaBlobUrl] = useState<string | null>(null)
|
||||
const [proofMediaType, setProofMediaType] = useState<'image' | 'video' | null>(null)
|
||||
|
||||
// Multiple proof files
|
||||
const [proofFiles, setProofFiles] = useState<Array<{ id: number; url: string; type: 'image' | 'video' }>>([])
|
||||
|
||||
// Bonus proof media
|
||||
const [bonusProofMedia, setBonusProofMedia] = useState<Record<number, { url: string; type: 'image' | 'video' }>>({})
|
||||
|
||||
// Bonus proof files (multiple)
|
||||
const [bonusProofFiles, setBonusProofFiles] = useState<Record<number, Array<{ id: number; url: string; type: 'image' | 'video' }>>>({})
|
||||
|
||||
// Lightbox state
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false)
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0)
|
||||
const [lightboxItems, setLightboxItems] = useState<Array<{ url: string; type: 'image' | 'video' }>>([])
|
||||
|
||||
// Dispute creation
|
||||
const [showDisputeForm, setShowDisputeForm] = useState(false)
|
||||
const [disputeReason, setDisputeReason] = useState('')
|
||||
@@ -50,9 +61,20 @@ export function AssignmentDetailPage() {
|
||||
if (proofMediaBlobUrl) {
|
||||
URL.revokeObjectURL(proofMediaBlobUrl)
|
||||
}
|
||||
proofFiles.forEach(file => {
|
||||
URL.revokeObjectURL(file.url)
|
||||
})
|
||||
Object.values(bonusProofMedia).forEach(media => {
|
||||
URL.revokeObjectURL(media.url)
|
||||
})
|
||||
Object.values(bonusProofFiles).forEach(files => {
|
||||
files.forEach(file => {
|
||||
URL.revokeObjectURL(file.url)
|
||||
})
|
||||
})
|
||||
lightboxItems.forEach(item => {
|
||||
URL.revokeObjectURL(item.url)
|
||||
})
|
||||
}
|
||||
}, [id])
|
||||
|
||||
@@ -64,8 +86,20 @@ export function AssignmentDetailPage() {
|
||||
const data = await assignmentsApi.getDetail(parseInt(id))
|
||||
setAssignment(data)
|
||||
|
||||
// Load proof media if exists
|
||||
if (data.proof_image_url) {
|
||||
// Load proof files if exists (new multi-file support)
|
||||
if (data.proof_files && data.proof_files.length > 0) {
|
||||
const files: Array<{ id: number; url: string; type: 'image' | 'video' }> = []
|
||||
for (const proofFile of data.proof_files) {
|
||||
try {
|
||||
const { url, type } = await assignmentsApi.getProofFileMediaUrl(parseInt(id), proofFile.id)
|
||||
files.push({ id: proofFile.id, url, type })
|
||||
} catch {
|
||||
// Ignore error, file just won't show
|
||||
}
|
||||
}
|
||||
setProofFiles(files)
|
||||
} else if (data.proof_image_url) {
|
||||
// Legacy: Load single proof media if exists
|
||||
try {
|
||||
const { url, type } = await assignmentsApi.getProofMediaUrl(parseInt(id))
|
||||
setProofMediaBlobUrl(url)
|
||||
@@ -75,11 +109,26 @@ export function AssignmentDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Load bonus proof media for playthrough
|
||||
// Load bonus proof files for playthrough
|
||||
if (data.is_playthrough && data.bonus_challenges) {
|
||||
const bonusMedia: Record<number, { url: string; type: 'image' | 'video' }> = {}
|
||||
const bonusFiles: Record<number, Array<{ id: number; url: string; type: 'image' | 'video' }>> = {}
|
||||
|
||||
for (const bonus of data.bonus_challenges) {
|
||||
if (bonus.proof_image_url) {
|
||||
// New multi-file support
|
||||
if (bonus.proof_files && bonus.proof_files.length > 0) {
|
||||
const files: Array<{ id: number; url: string; type: 'image' | 'video' }> = []
|
||||
for (const proofFile of bonus.proof_files) {
|
||||
try {
|
||||
const { url, type } = await assignmentsApi.getBonusProofFileMediaUrl(parseInt(id), bonus.id, proofFile.id)
|
||||
files.push({ id: proofFile.id, url, type })
|
||||
} catch {
|
||||
// Ignore error, file just won't show
|
||||
}
|
||||
}
|
||||
bonusFiles[bonus.id] = files
|
||||
} else if (bonus.proof_image_url) {
|
||||
// Legacy: single file
|
||||
try {
|
||||
const { url, type } = await assignmentsApi.getBonusProofMediaUrl(parseInt(id), bonus.id)
|
||||
bonusMedia[bonus.id] = { url, type }
|
||||
@@ -88,7 +137,9 @@ export function AssignmentDetailPage() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setBonusProofMedia(bonusMedia)
|
||||
setBonusProofFiles(bonusFiles)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
@@ -200,6 +251,24 @@ export function AssignmentDetailPage() {
|
||||
return `${hours}ч ${minutes}м`
|
||||
}
|
||||
|
||||
const openLightbox = (items: Array<{ url: string; type: 'image' | 'video' }>, index: number) => {
|
||||
setLightboxItems(items)
|
||||
setLightboxIndex(index)
|
||||
setLightboxOpen(true)
|
||||
}
|
||||
|
||||
const closeLightbox = () => {
|
||||
setLightboxOpen(false)
|
||||
}
|
||||
|
||||
const nextLightboxItem = () => {
|
||||
setLightboxIndex((prev) => (prev + 1) % lightboxItems.length)
|
||||
}
|
||||
|
||||
const prevLightboxItem = () => {
|
||||
setLightboxIndex((prev) => (prev - 1 + lightboxItems.length) % lightboxItems.length)
|
||||
}
|
||||
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
@@ -332,6 +401,18 @@ export function AssignmentDetailPage() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Download link */}
|
||||
{(assignment.game?.download_url || assignment.challenge?.game.download_url) && (
|
||||
<a
|
||||
href={assignment.is_playthrough ? assignment.game?.download_url : assignment.challenge?.game.download_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1.5 bg-neon-500/20 text-neon-400 rounded-lg text-sm font-medium border border-neon-500/30 flex items-center gap-1.5 hover:bg-neon-500/30 transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Скачать игру
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-dark-600 text-sm text-gray-400 space-y-1">
|
||||
@@ -401,9 +482,44 @@ export function AssignmentDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">{bonus.challenge.description}</p>
|
||||
{bonus.status === 'completed' && (bonus.proof_url || bonus.proof_image_url || bonus.proof_comment) && (
|
||||
{bonus.status === 'completed' && (bonus.proof_url || bonus.proof_image_url || bonus.proof_comment || bonusProofFiles[bonus.id]) && (
|
||||
<div className="mt-2 text-xs space-y-2">
|
||||
{bonusProofMedia[bonus.id] && (
|
||||
{/* Multiple proof files */}
|
||||
{bonusProofFiles[bonus.id] && bonusProofFiles[bonus.id].length > 0 && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{bonusProofFiles[bonus.id].map((file, index) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="relative rounded-lg overflow-hidden border border-dark-600 cursor-pointer hover:border-neon-500/50 transition-all w-24 h-24"
|
||||
onClick={() => openLightbox(bonusProofFiles[bonus.id], index)}
|
||||
>
|
||||
{file.type === 'video' ? (
|
||||
<div className="relative w-full h-full">
|
||||
<video
|
||||
src={file.url}
|
||||
className="w-full h-full object-cover bg-dark-900"
|
||||
preload="metadata"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<div className="w-6 h-6 rounded-full bg-neon-500/80 flex items-center justify-center">
|
||||
<div className="w-0 h-0 border-l-4 border-l-white border-y-3 border-y-transparent ml-0.5"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={file.url}
|
||||
alt={`Proof ${index + 1}`}
|
||||
className="w-full h-full object-cover bg-dark-900"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legacy: single proof media */}
|
||||
{(!bonusProofFiles[bonus.id] || bonusProofFiles[bonus.id].length === 0) && bonusProofMedia[bonus.id] && (
|
||||
<div className="rounded-lg overflow-hidden border border-dark-600 max-w-xs">
|
||||
{bonusProofMedia[bonus.id].type === 'video' ? (
|
||||
<video
|
||||
@@ -413,16 +529,20 @@ export function AssignmentDetailPage() {
|
||||
preload="metadata"
|
||||
/>
|
||||
) : (
|
||||
<a href={bonusProofMedia[bonus.id].url} target="_blank" rel="noopener noreferrer">
|
||||
<button
|
||||
onClick={() => openLightbox([bonusProofMedia[bonus.id]], 0)}
|
||||
className="w-full"
|
||||
>
|
||||
<img
|
||||
src={bonusProofMedia[bonus.id].url}
|
||||
alt="Proof"
|
||||
className="w-full h-auto max-h-32 object-cover hover:opacity-80 transition-opacity"
|
||||
/>
|
||||
</a>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bonus.proof_url && (
|
||||
<a
|
||||
href={bonus.proof_url}
|
||||
@@ -545,8 +665,47 @@ export function AssignmentDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Proof media (image or video) */}
|
||||
{assignment.proof_image_url && (
|
||||
{/* Proof files gallery (multiple proofs) */}
|
||||
{proofFiles.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{proofFiles.map((file, index) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="relative rounded-xl overflow-hidden border border-dark-600 cursor-pointer hover:border-neon-500/50 transition-all group"
|
||||
onClick={() => openLightbox(proofFiles, index)}
|
||||
>
|
||||
{file.type === 'video' ? (
|
||||
<div className="relative">
|
||||
<video
|
||||
src={file.url}
|
||||
className="w-full h-48 object-cover bg-dark-900"
|
||||
preload="metadata"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 group-hover:bg-black/30 transition-all">
|
||||
<div className="w-12 h-12 rounded-full bg-neon-500/80 flex items-center justify-center">
|
||||
<div className="w-0 h-0 border-l-8 border-l-white border-y-6 border-y-transparent ml-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={file.url}
|
||||
alt={`Proof ${index + 1}`}
|
||||
className="w-full h-48 object-cover bg-dark-900 group-hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute top-2 right-2 px-2 py-1 bg-dark-900/80 rounded text-xs text-gray-300">
|
||||
{index + 1}/{proofFiles.length}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legacy: Single proof media (for backwards compatibility) */}
|
||||
{proofFiles.length === 0 && assignment.proof_image_url && (
|
||||
<div className="mb-4 rounded-xl overflow-hidden border border-dark-600">
|
||||
{proofMediaBlobUrl ? (
|
||||
proofMediaType === 'video' ? (
|
||||
@@ -557,11 +716,16 @@ export function AssignmentDetailPage() {
|
||||
preload="metadata"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={proofMediaBlobUrl}
|
||||
alt="Proof"
|
||||
className="w-full max-h-96 object-contain bg-dark-900"
|
||||
/>
|
||||
<button
|
||||
onClick={() => openLightbox([{ url: proofMediaBlobUrl, type: 'image' }], 0)}
|
||||
className="w-full"
|
||||
>
|
||||
<img
|
||||
src={proofMediaBlobUrl}
|
||||
alt="Proof"
|
||||
className="w-full max-h-96 object-contain bg-dark-900 hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<div className="w-full h-48 bg-dark-900 flex items-center justify-center">
|
||||
@@ -594,7 +758,7 @@ export function AssignmentDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!assignment.proof_image_url && !assignment.proof_url && (
|
||||
{proofFiles.length === 0 && !assignment.proof_image_url && !assignment.proof_url && (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-dark-700 flex items-center justify-center">
|
||||
<Image className="w-6 h-6 text-gray-600" />
|
||||
@@ -810,6 +974,69 @@ export function AssignmentDetailPage() {
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Lightbox modal */}
|
||||
{lightboxOpen && lightboxItems.length > 0 && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
|
||||
onClick={closeLightbox}
|
||||
>
|
||||
<button
|
||||
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-dark-700/80 hover:bg-dark-600 flex items-center justify-center text-gray-400 hover:text-white transition-all z-10"
|
||||
onClick={closeLightbox}
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
{lightboxItems.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
className="absolute left-4 w-12 h-12 rounded-full bg-dark-700/80 hover:bg-dark-600 flex items-center justify-center text-gray-400 hover:text-white transition-all z-10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
prevLightboxItem()
|
||||
}}
|
||||
>
|
||||
<ChevronLeft className="w-8 h-8" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="absolute right-4 w-12 h-12 rounded-full bg-dark-700/80 hover:bg-dark-600 flex items-center justify-center text-gray-400 hover:text-white transition-all z-10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
nextLightboxItem()
|
||||
}}
|
||||
>
|
||||
<ChevronRight className="w-8 h-8" />
|
||||
</button>
|
||||
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 px-4 py-2 bg-dark-700/80 rounded-full text-white text-sm z-10">
|
||||
{lightboxIndex + 1} / {lightboxItems.length}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="max-w-7xl max-h-[90vh] w-full h-full flex items-center justify-center p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{lightboxItems[lightboxIndex].type === 'video' ? (
|
||||
<video
|
||||
src={lightboxItems[lightboxIndex].url}
|
||||
controls
|
||||
autoPlay
|
||||
className="max-w-full max-h-full"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={lightboxItems[lightboxIndex].url}
|
||||
alt="Proof"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequ
|
||||
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
|
||||
import { SpinWheel } from '@/components/SpinWheel'
|
||||
import { EventBanner } from '@/components/EventBanner'
|
||||
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target } from 'lucide-react'
|
||||
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target, Download } from 'lucide-react'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { useConfirm } from '@/store/confirm'
|
||||
|
||||
@@ -25,7 +25,7 @@ export function PlayPage() {
|
||||
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const [proofFile, setProofFile] = useState<File | null>(null)
|
||||
const [proofFiles, setProofFiles] = useState<File[]>([])
|
||||
const [proofUrl, setProofUrl] = useState('')
|
||||
const [comment, setComment] = useState('')
|
||||
const [isCompleting, setIsCompleting] = useState(false)
|
||||
@@ -57,7 +57,7 @@ export function PlayPage() {
|
||||
|
||||
// Bonus challenge completion
|
||||
const [expandedBonusId, setExpandedBonusId] = useState<number | null>(null)
|
||||
const [bonusProofFile, setBonusProofFile] = useState<File | null>(null)
|
||||
const [bonusProofFiles, setBonusProofFiles] = useState<File[]>([])
|
||||
const [bonusProofUrl, setBonusProofUrl] = useState('')
|
||||
const [bonusComment, setBonusComment] = useState('')
|
||||
const [isCompletingBonus, setIsCompletingBonus] = useState(false)
|
||||
@@ -232,12 +232,12 @@ export function PlayPage() {
|
||||
// For playthrough: allow file, URL, or comment
|
||||
// For challenges: require file or URL
|
||||
if (currentAssignment.is_playthrough) {
|
||||
if (!proofFile && !proofUrl && !comment) {
|
||||
if (proofFiles.length === 0 && !proofUrl && !comment) {
|
||||
toast.warning('Пожалуйста, предоставьте доказательство (файл, ссылку или комментарий)')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (!proofFile && !proofUrl) {
|
||||
if (proofFiles.length === 0 && !proofUrl) {
|
||||
toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
|
||||
return
|
||||
}
|
||||
@@ -246,12 +246,12 @@ export function PlayPage() {
|
||||
setIsCompleting(true)
|
||||
try {
|
||||
const result = await wheelApi.complete(currentAssignment.id, {
|
||||
proof_file: proofFile || undefined,
|
||||
proof_files: proofFiles.length > 0 ? proofFiles : undefined,
|
||||
proof_url: proofUrl || undefined,
|
||||
comment: comment || undefined,
|
||||
})
|
||||
toast.success(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`)
|
||||
setProofFile(null)
|
||||
setProofFiles([])
|
||||
setProofUrl('')
|
||||
setComment('')
|
||||
await loadData()
|
||||
@@ -291,7 +291,7 @@ export function PlayPage() {
|
||||
|
||||
const handleBonusComplete = async (bonusId: number) => {
|
||||
if (!currentAssignment) return
|
||||
if (!bonusProofFile && !bonusProofUrl && !bonusComment) {
|
||||
if (bonusProofFiles.length === 0 && !bonusProofUrl && !bonusComment) {
|
||||
toast.warning('Прикрепите файл, ссылку или комментарий')
|
||||
return
|
||||
}
|
||||
@@ -302,13 +302,13 @@ export function PlayPage() {
|
||||
currentAssignment.id,
|
||||
bonusId,
|
||||
{
|
||||
proof_file: bonusProofFile || undefined,
|
||||
proof_files: bonusProofFiles.length > 0 ? bonusProofFiles : undefined,
|
||||
proof_url: bonusProofUrl || undefined,
|
||||
comment: bonusComment || undefined,
|
||||
}
|
||||
)
|
||||
toast.success(`Бонус отмечен! +${result.points_earned} очков начислится при завершении игры`)
|
||||
setBonusProofFile(null)
|
||||
setBonusProofFiles([])
|
||||
setBonusProofUrl('')
|
||||
setBonusComment('')
|
||||
setExpandedBonusId(null)
|
||||
@@ -965,11 +965,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">
|
||||
{currentAssignment.is_playthrough
|
||||
? currentAssignment.game?.title
|
||||
: currentAssignment.challenge?.game.title}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<p className="text-xl font-bold text-white">
|
||||
{currentAssignment.is_playthrough
|
||||
? currentAssignment.game?.title
|
||||
: currentAssignment.challenge?.game.title}
|
||||
</p>
|
||||
{(currentAssignment.is_playthrough
|
||||
? currentAssignment.game?.download_url
|
||||
: currentAssignment.challenge?.game.download_url) && (
|
||||
<a
|
||||
href={currentAssignment.is_playthrough
|
||||
? currentAssignment.game?.download_url
|
||||
: currentAssignment.challenge?.game.download_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1.5 bg-neon-500/20 text-neon-400 rounded-lg text-sm font-medium border border-neon-500/30 flex items-center gap-1.5 hover:bg-neon-500/30 transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Скачать игру
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentAssignment.is_playthrough ? (
|
||||
@@ -1023,7 +1040,7 @@ export function PlayPage() {
|
||||
onClick={() => {
|
||||
if (bonus.status === 'pending') {
|
||||
setExpandedBonusId(expandedBonusId === bonus.id ? null : bonus.id)
|
||||
setBonusProofFile(null)
|
||||
setBonusProofFiles([])
|
||||
setBonusProofUrl('')
|
||||
setBonusComment('')
|
||||
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
|
||||
@@ -1062,24 +1079,40 @@ export function PlayPage() {
|
||||
ref={bonusFileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
validateAndSetFile(e.target.files?.[0] || null, setBonusProofFile, bonusFileInputRef)
|
||||
const files = Array.from(e.target.files || [])
|
||||
setBonusProofFiles(prev => [...prev, ...files])
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
{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>
|
||||
{bonusProofFiles.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{bonusProofFiles.map((file, index) => (
|
||||
<div key={index} 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">{file.name}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setBonusProofFiles(prev => prev.filter((_, i) => i !== index))
|
||||
}}
|
||||
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()
|
||||
setBonusProofFile(null)
|
||||
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
|
||||
bonusFileInputRef.current?.click()
|
||||
}}
|
||||
className="p-1 rounded text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
|
||||
className="w-full p-2 border border-dashed border-neon-500/30 rounded-lg text-neon-400 hover:border-neon-500/50 hover:bg-neon-500/5 transition-all text-sm flex items-center justify-center gap-2"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
<Upload className="w-4 h-4" />
|
||||
Добавить еще файл
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1121,7 +1154,7 @@ export function PlayPage() {
|
||||
handleBonusComplete(bonus.id)
|
||||
}}
|
||||
isLoading={isCompletingBonus}
|
||||
disabled={!bonusProofFile && !bonusProofUrl && !bonusComment}
|
||||
disabled={bonusProofFiles.length === 0 && !bonusProofUrl && !bonusComment}
|
||||
icon={<Check className="w-3 h-3" />}
|
||||
>
|
||||
Выполнено
|
||||
@@ -1131,7 +1164,7 @@ export function PlayPage() {
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setBonusProofFile(null)
|
||||
setBonusProofFiles([])
|
||||
setBonusProofUrl('')
|
||||
setBonusComment('')
|
||||
setExpandedBonusId(null)
|
||||
@@ -1202,19 +1235,37 @@ export function PlayPage() {
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => validateAndSetFile(e.target.files?.[0] || null, setProofFile, fileInputRef)}
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
setProofFiles(prev => [...prev, ...files])
|
||||
// Reset input to allow selecting same files again
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
|
||||
{proofFile ? (
|
||||
<div className="flex items-center gap-2 p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||
<span className="text-white flex-1 truncate">{proofFile.name}</span>
|
||||
<button
|
||||
onClick={() => setProofFile(null)}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
|
||||
{proofFiles.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{proofFiles.map((file, index) => (
|
||||
<div key={index} className="flex items-center gap-2 p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||
<span className="text-white flex-1 truncate">{file.name}</span>
|
||||
<button
|
||||
onClick={() => setProofFiles(proofFiles.filter((_, i) => i !== index))}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<NeonButton
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
icon={<Upload className="w-4 h-4" />}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
Добавить ещё файлы
|
||||
</NeonButton>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
@@ -1224,10 +1275,10 @@ export function PlayPage() {
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
icon={<Upload className="w-4 h-4" />}
|
||||
>
|
||||
Выбрать файл
|
||||
Выбрать файлы
|
||||
</NeonButton>
|
||||
<p className="text-xs text-gray-500 mt-2 text-center">
|
||||
Макс. 15 МБ для изображений, 30 МБ для видео
|
||||
Можно выбрать несколько файлов. Макс. 15 МБ для изображений, 30 МБ для видео
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1257,8 +1308,8 @@ export function PlayPage() {
|
||||
onClick={handleComplete}
|
||||
isLoading={isCompleting}
|
||||
disabled={currentAssignment.is_playthrough
|
||||
? (!proofFile && !proofUrl && !comment)
|
||||
: (!proofFile && !proofUrl)
|
||||
? (proofFiles.length === 0 && !proofUrl && !comment)
|
||||
: (proofFiles.length === 0 && !proofUrl)
|
||||
}
|
||||
icon={<Check className="w-4 h-4" />}
|
||||
>
|
||||
|
||||
@@ -154,6 +154,7 @@ export interface GameShort {
|
||||
id: number
|
||||
title: string
|
||||
cover_url: string | null
|
||||
download_url?: string
|
||||
game_type?: GameType
|
||||
}
|
||||
|
||||
@@ -226,7 +227,8 @@ export interface BonusAssignment {
|
||||
challenge: Challenge
|
||||
status: BonusAssignmentStatus
|
||||
proof_url: string | null
|
||||
proof_image_url: string | null
|
||||
proof_image_url: string | null // Legacy, for backward compatibility
|
||||
proof_files?: ProofFile[] // Multiple uploaded files
|
||||
proof_comment: string | null
|
||||
points_earned: number
|
||||
completed_at: string | null
|
||||
@@ -614,6 +616,13 @@ export interface Dispute {
|
||||
resolved_at: string | null
|
||||
}
|
||||
|
||||
export interface ProofFile {
|
||||
id: number
|
||||
file_type: 'image' | 'video'
|
||||
order_index: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AssignmentDetail {
|
||||
id: number
|
||||
challenge: Challenge | null // null for playthrough
|
||||
@@ -623,7 +632,8 @@ export interface AssignmentDetail {
|
||||
participant: User
|
||||
status: AssignmentStatus
|
||||
proof_url: string | null
|
||||
proof_image_url: string | null
|
||||
proof_image_url: string | null // Legacy, for backward compatibility
|
||||
proof_files: ProofFile[] // Multiple uploaded files
|
||||
proof_comment: string | null
|
||||
points_earned: number
|
||||
streak_at_completion: number | null
|
||||
|
||||
Reference in New Issue
Block a user