import { useState, useEffect } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { assignmentsApi } from '@/api' import type { AssignmentDetail } from '@/types' import { GlassCard, NeonButton } from '@/components/ui' import { useAuthStore } from '@/store/auth' import { useToast } from '@/store/toast' import { ArrowLeft, Loader2, ExternalLink, Image, MessageSquare, ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle, Send, Flag, Gamepad2, Zap, Trophy, Download, ChevronLeft, ChevronRight, X } from 'lucide-react' export function AssignmentDetailPage() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() const user = useAuthStore((state) => state.user) const toast = useToast() const [assignment, setAssignment] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) const [proofMediaBlobUrl, setProofMediaBlobUrl] = useState(null) const [proofMediaType, setProofMediaType] = useState<'image' | 'video' | null>(null) // Multiple proof files const [proofFiles, setProofFiles] = useState>([]) // Bonus proof media const [bonusProofMedia, setBonusProofMedia] = useState>({}) // Bonus proof files (multiple) const [bonusProofFiles, setBonusProofFiles] = useState>>({}) // Lightbox state const [lightboxOpen, setLightboxOpen] = useState(false) const [lightboxIndex, setLightboxIndex] = useState(0) const [lightboxItems, setLightboxItems] = useState>([]) // Dispute creation const [showDisputeForm, setShowDisputeForm] = useState(false) const [disputeReason, setDisputeReason] = useState('') const [isCreatingDispute, setIsCreatingDispute] = useState(false) // Bonus dispute creation const [activeBonusDisputeId, setActiveBonusDisputeId] = useState(null) const [bonusDisputeReason, setBonusDisputeReason] = useState('') const [isCreatingBonusDispute, setIsCreatingBonusDispute] = useState(false) // Comment const [commentText, setCommentText] = useState('') const [isAddingComment, setIsAddingComment] = useState(false) // Voting const [isVoting, setIsVoting] = useState(false) useEffect(() => { loadAssignment() return () => { // Cleanup blob URLs on unmount 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]) const loadAssignment = async () => { if (!id) return setIsLoading(true) setError(null) try { const data = await assignmentsApi.getDetail(parseInt(id)) setAssignment(data) // 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) setProofMediaType(type) } catch { // Ignore error, media just won't show } } // Load bonus proof files for playthrough if (data.is_playthrough && data.bonus_challenges) { const bonusMedia: Record = {} const bonusFiles: Record> = {} for (const bonus of data.bonus_challenges) { // 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 } } catch { // Ignore error, media just won't show } } } setBonusProofMedia(bonusMedia) setBonusProofFiles(bonusFiles) } } catch (err: unknown) { const error = err as { response?: { data?: { detail?: string } } } setError(error.response?.data?.detail || 'Не удалось загрузить данные') } finally { setIsLoading(false) } } const handleCreateDispute = async () => { if (!id || !disputeReason.trim()) return setIsCreatingDispute(true) try { await assignmentsApi.createDispute(parseInt(id), disputeReason) setDisputeReason('') setShowDisputeForm(false) await loadAssignment() } catch (err: unknown) { const error = err as { response?: { data?: { detail?: string } } } toast.error(error.response?.data?.detail || 'Не удалось создать оспаривание') } finally { setIsCreatingDispute(false) } } const handleCreateBonusDispute = async (bonusId: number) => { if (!bonusDisputeReason.trim()) return setIsCreatingBonusDispute(true) try { await assignmentsApi.createBonusDispute(bonusId, bonusDisputeReason) setBonusDisputeReason('') setActiveBonusDisputeId(null) await loadAssignment() toast.success('Оспаривание бонуса создано') } catch (err: unknown) { const error = err as { response?: { data?: { detail?: string } } } toast.error(error.response?.data?.detail || 'Не удалось создать оспаривание') } finally { setIsCreatingBonusDispute(false) } } const handleBonusVote = async (disputeId: number, vote: boolean) => { setIsVoting(true) try { await assignmentsApi.vote(disputeId, vote) await loadAssignment() } catch (err: unknown) { const error = err as { response?: { data?: { detail?: string } } } toast.error(error.response?.data?.detail || 'Не удалось проголосовать') } finally { setIsVoting(false) } } const handleVote = async (vote: boolean) => { if (!assignment?.dispute) return setIsVoting(true) try { await assignmentsApi.vote(assignment.dispute.id, vote) await loadAssignment() } catch (err: unknown) { const error = err as { response?: { data?: { detail?: string } } } toast.error(error.response?.data?.detail || 'Не удалось проголосовать') } finally { setIsVoting(false) } } const handleAddComment = async () => { if (!assignment?.dispute || !commentText.trim()) return setIsAddingComment(true) try { await assignmentsApi.addComment(assignment.dispute.id, commentText) setCommentText('') await loadAssignment() } catch (err: unknown) { const error = err as { response?: { data?: { detail?: string } } } toast.error(error.response?.data?.detail || 'Не удалось добавить комментарий') } finally { setIsAddingComment(false) } } const formatDate = (dateString: string) => { return new Date(dateString).toLocaleString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit', }) } const getTimeRemaining = (expiresAt: string) => { const now = new Date() const expires = new Date(expiresAt) const diff = expires.getTime() - now.getTime() if (diff <= 0) return 'Истекло' const hours = Math.floor(diff / (1000 * 60 * 60)) const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)) 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': return { color: 'bg-green-500/20 text-green-400 border-green-500/30', icon: , text: 'Выполнено', } case 'dropped': return { color: 'bg-red-500/20 text-red-400 border-red-500/30', icon: , text: 'Пропущено', } case 'returned': return { color: 'bg-orange-500/20 text-orange-400 border-orange-500/30', icon: , text: 'Возвращено', } default: return { color: 'bg-neon-500/20 text-neon-400 border-neon-500/30', icon: , text: 'Активно', } } } if (isLoading) { return (

Загрузка...

) } if (error || !assignment) { return (

{error || 'Задание не найдено'}

navigate(-1)}> Назад
) } const dispute = assignment.dispute const status = getStatusConfig(assignment.status) return (
{/* Header */}

Детали выполнения

Просмотр доказательства

{/* Challenge/Playthrough info */}

{assignment.is_playthrough ? assignment.game?.title : assignment.challenge?.game.title}

{assignment.is_playthrough ? 'Прохождение игры' : assignment.challenge?.title}

{assignment.is_playthrough && ( Прохождение )} {status.icon} {status.text}

{assignment.is_playthrough ? assignment.playthrough_info?.description : assignment.challenge?.description}

+{assignment.is_playthrough ? assignment.playthrough_info?.points : assignment.challenge?.points} очков {!assignment.is_playthrough && assignment.challenge && ( <> {assignment.challenge.difficulty} {assignment.challenge.estimated_time && ( ~{assignment.challenge.estimated_time} мин )} )} {/* Download link */} {(assignment.game?.download_url || assignment.challenge?.game.download_url) && ( Скачать игру )}

Выполнил:{' '} {assignment.participant.nickname}

{assignment.completed_at && (

Дата:{' '} {formatDate(assignment.completed_at)}

)} {assignment.points_earned > 0 && (

Получено очков:{' '} {assignment.points_earned}

)}
{/* Bonus challenges for playthrough */} {assignment.is_playthrough && assignment.bonus_challenges && assignment.bonus_challenges.length > 0 && (

Бонусные челленджи

Выполнено: {assignment.bonus_challenges.filter((b: { status: string }) => b.status === 'completed').length} из {assignment.bonus_challenges.length}

{assignment.bonus_challenges.map((bonus) => (
{bonus.dispute ? ( ) : bonus.status === 'completed' ? ( ) : null} {bonus.challenge.title} {bonus.dispute && ( {bonus.dispute.status === 'open' ? 'Оспаривается' : bonus.dispute.status === 'valid' ? 'Валидно' : 'Невалидно'} )}

{bonus.challenge.description}

{bonus.status === 'completed' && (bonus.proof_url || bonus.proof_image_url || bonus.proof_comment || bonusProofFiles[bonus.id]) && (
{/* Multiple proof files */} {bonusProofFiles[bonus.id] && bonusProofFiles[bonus.id].length > 0 && (
{bonusProofFiles[bonus.id].map((file, index) => (
openLightbox(bonusProofFiles[bonus.id], index)} > {file.type === 'video' ? (
) : ( {`Proof )}
))}
)} {/* Legacy: single proof media */} {(!bonusProofFiles[bonus.id] || bonusProofFiles[bonus.id].length === 0) && bonusProofMedia[bonus.id] && (
{bonusProofMedia[bonus.id].type === 'video' ? (
)} {bonus.proof_url && ( {bonus.proof_url} )} {bonus.proof_comment && (

"{bonus.proof_comment}"

)}
)} {/* Bonus dispute form */} {activeBonusDisputeId === bonus.id && (