Улучшение системы оспариваний и исправления
- Оспаривания теперь требуют решения админа после 24ч голосования - Можно повторно оспаривать после разрешённых споров - Исправлены бонусные очки при перепрохождении после оспаривания - Сброс серии при невалидном пруфе - Колесо показывает только доступные игры - Rate limiting только через backend (RATE_LIMIT_ENABLED)
This commit is contained in:
@@ -32,6 +32,7 @@ import {
|
||||
AdminDashboardPage,
|
||||
AdminUsersPage,
|
||||
AdminMarathonsPage,
|
||||
AdminDisputesPage,
|
||||
AdminLogsPage,
|
||||
AdminBroadcastPage,
|
||||
AdminContentPage,
|
||||
@@ -208,6 +209,7 @@ function App() {
|
||||
<Route index element={<AdminDashboardPage />} />
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="marathons" element={<AdminMarathonsPage />} />
|
||||
<Route path="disputes" element={<AdminDisputesPage />} />
|
||||
<Route path="logs" element={<AdminLogsPage />} />
|
||||
<Route path="broadcast" element={<AdminBroadcastPage />} />
|
||||
<Route path="content" element={<AdminContentPage />} />
|
||||
|
||||
@@ -7,7 +7,8 @@ import type {
|
||||
AdminLogsResponse,
|
||||
BroadcastResponse,
|
||||
StaticContent,
|
||||
DashboardStats
|
||||
DashboardStats,
|
||||
AdminDispute
|
||||
} from '@/types'
|
||||
|
||||
export const adminApi = {
|
||||
@@ -125,6 +126,19 @@ export const adminApi = {
|
||||
deleteContent: async (key: string): Promise<void> => {
|
||||
await client.delete(`/admin/content/${key}`)
|
||||
},
|
||||
|
||||
// Disputes
|
||||
listDisputes: async (status: 'pending' | 'open' | 'all' = 'pending'): Promise<AdminDispute[]> => {
|
||||
const response = await client.get<AdminDispute[]>('/admin/disputes', { params: { status } })
|
||||
return response.data
|
||||
},
|
||||
|
||||
resolveDispute: async (disputeId: number, isValid: boolean): Promise<{ message: string }> => {
|
||||
const response = await client.post<{ message: string }>(`/admin/disputes/${disputeId}/resolve`, {
|
||||
is_valid: isValid,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
// Public content API (no auth required)
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import client from './client'
|
||||
import type { AssignmentDetail, Dispute, DisputeComment, ReturnedAssignment } from '@/types'
|
||||
import type { AssignmentDetail, Dispute, DisputeComment, ReturnedAssignment, BonusAssignment } from '@/types'
|
||||
|
||||
export interface BonusCompleteResult {
|
||||
bonus_assignment_id: number
|
||||
points_earned: number
|
||||
total_bonus_points: number
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
// Get detailed assignment info with proofs and dispute
|
||||
@@ -14,6 +20,12 @@ export const assignmentsApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Create a dispute against a bonus assignment
|
||||
createBonusDispute: async (bonusId: number, reason: string): Promise<Dispute> => {
|
||||
const response = await client.post<Dispute>(`/bonus-assignments/${bonusId}/dispute`, { reason })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Add a comment to a dispute
|
||||
addComment: async (disputeId: number, text: string): Promise<DisputeComment> => {
|
||||
const response = await client.post<DisputeComment>(`/disputes/${disputeId}/comments`, { text })
|
||||
@@ -44,4 +56,51 @@ export const assignmentsApi = {
|
||||
type: isVideo ? 'video' : 'image',
|
||||
}
|
||||
},
|
||||
|
||||
// Get bonus assignments for a playthrough assignment
|
||||
getBonusAssignments: async (assignmentId: number): Promise<BonusAssignment[]> => {
|
||||
const response = await client.get<BonusAssignment[]>(`/assignments/${assignmentId}/bonus`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Complete a bonus challenge
|
||||
completeBonusAssignment: async (
|
||||
assignmentId: number,
|
||||
bonusId: number,
|
||||
data: { proof_file?: File; proof_url?: string; comment?: string }
|
||||
): Promise<BonusCompleteResult> => {
|
||||
const formData = new FormData()
|
||||
if (data.proof_file) {
|
||||
formData.append('proof_file', data.proof_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,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get bonus proof media as blob URL (supports both images and videos)
|
||||
getBonusProofMediaUrl: async (
|
||||
assignmentId: number,
|
||||
bonusId: number
|
||||
): Promise<{ url: string; type: 'image' | 'video' }> => {
|
||||
const response = await client.get(
|
||||
`/assignments/${assignmentId}/bonus/${bonusId}/proof-media`,
|
||||
{ responseType: 'blob' }
|
||||
)
|
||||
const contentType = response.headers['content-type'] || ''
|
||||
const isVideo = contentType.startsWith('video/')
|
||||
return {
|
||||
url: URL.createObjectURL(response.data),
|
||||
type: isVideo ? 'video' : 'image',
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
import client from './client'
|
||||
import type { Game, GameStatus, Challenge, ChallengePreview, ChallengesPreviewResponse } from '@/types'
|
||||
import type { Game, GameStatus, GameType, ProofType, Challenge, ChallengePreview, ChallengesPreviewResponse, AvailableGamesCount } from '@/types'
|
||||
|
||||
export interface CreateGameData {
|
||||
title: string
|
||||
download_url: string
|
||||
genre?: string
|
||||
cover_url?: string
|
||||
// Game type fields
|
||||
game_type?: GameType
|
||||
playthrough_points?: number
|
||||
playthrough_description?: string
|
||||
playthrough_proof_type?: ProofType
|
||||
playthrough_proof_hint?: string
|
||||
}
|
||||
|
||||
export interface UpdateGameData {
|
||||
title?: string
|
||||
download_url?: string
|
||||
genre?: string
|
||||
game_type?: GameType
|
||||
playthrough_points?: number
|
||||
playthrough_description?: string
|
||||
playthrough_proof_type?: ProofType
|
||||
playthrough_proof_hint?: string
|
||||
}
|
||||
|
||||
export interface CreateChallengeData {
|
||||
@@ -45,6 +62,21 @@ export const gamesApi = {
|
||||
await client.delete(`/games/${id}`)
|
||||
},
|
||||
|
||||
update: async (id: number, data: UpdateGameData): Promise<Game> => {
|
||||
const response = await client.patch<Game>(`/games/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getAvailableGamesCount: async (marathonId: number): Promise<AvailableGamesCount> => {
|
||||
const response = await client.get<AvailableGamesCount>(`/marathons/${marathonId}/available-games-count`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getAvailableGames: async (marathonId: number): Promise<Game[]> => {
|
||||
const response = await client.get<Game[]>(`/marathons/${marathonId}/available-games`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
approve: async (id: number): Promise<Game> => {
|
||||
const response = await client.post<Game>(`/games/${id}/approve`)
|
||||
return response.data
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import client from './client'
|
||||
import type { Marathon, MarathonListItem, MarathonPublicInfo, MarathonUpdate, LeaderboardEntry, ParticipantWithUser, ParticipantRole, GameProposalMode } from '@/types'
|
||||
import type { Marathon, MarathonListItem, MarathonPublicInfo, MarathonUpdate, LeaderboardEntry, ParticipantWithUser, ParticipantRole, GameProposalMode, MarathonDispute } from '@/types'
|
||||
|
||||
export interface CreateMarathonData {
|
||||
title: string
|
||||
@@ -96,4 +96,20 @@ export const marathonsApi = {
|
||||
const response = await client.delete<Marathon>(`/marathons/${id}/cover`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Disputes management for organizers
|
||||
listDisputes: async (id: number, status: 'open' | 'all' = 'open'): Promise<MarathonDispute[]> => {
|
||||
const response = await client.get<MarathonDispute[]>(`/marathons/${id}/disputes`, {
|
||||
params: { status_filter: status }
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
resolveDispute: async (marathonId: number, disputeId: number, isValid: boolean): Promise<{ message: string }> => {
|
||||
const response = await client.post<{ message: string }>(
|
||||
`/marathons/${marathonId}/disputes/${disputeId}/resolve`,
|
||||
{ is_valid: isValid }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -191,14 +191,15 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
|
||||
const isEvent = isEventActivity(activity.type)
|
||||
const { title, details, extra } = formatActivityMessage(activity)
|
||||
|
||||
// Get assignment_id and dispute status for complete activities
|
||||
const activityData = activity.data as { assignment_id?: number; dispute_status?: string } | null
|
||||
// Get assignment_id, dispute status, and is_redo for complete activities
|
||||
const activityData = activity.data as { assignment_id?: number; dispute_status?: string; is_redo?: boolean } | null
|
||||
const assignmentId = activity.type === 'complete' && activityData?.assignment_id
|
||||
? activityData.assignment_id
|
||||
: null
|
||||
const disputeStatus = activity.type === 'complete' && activityData?.dispute_status
|
||||
? activityData.dispute_status
|
||||
: null
|
||||
const isRedo = activity.type === 'complete' && activityData?.is_redo === true
|
||||
|
||||
// Determine accent color based on activity type
|
||||
const getAccentConfig = () => {
|
||||
@@ -323,6 +324,12 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Детали
|
||||
</button>
|
||||
{isRedo && (
|
||||
<span className="text-xs text-purple-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-purple-500/10">
|
||||
<Zap className="w-3 h-3" />
|
||||
Перепрохождение
|
||||
</span>
|
||||
)}
|
||||
{disputeStatus === 'open' && (
|
||||
<span className="text-xs text-orange-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-orange-500/10">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
|
||||
@@ -23,11 +23,19 @@ export function AssignmentDetailPage() {
|
||||
const [proofMediaBlobUrl, setProofMediaBlobUrl] = useState<string | null>(null)
|
||||
const [proofMediaType, setProofMediaType] = useState<'image' | 'video' | null>(null)
|
||||
|
||||
// Bonus proof media
|
||||
const [bonusProofMedia, setBonusProofMedia] = useState<Record<number, { url: string; type: 'image' | 'video' }>>({})
|
||||
|
||||
// Dispute creation
|
||||
const [showDisputeForm, setShowDisputeForm] = useState(false)
|
||||
const [disputeReason, setDisputeReason] = useState('')
|
||||
const [isCreatingDispute, setIsCreatingDispute] = useState(false)
|
||||
|
||||
// Bonus dispute creation
|
||||
const [activeBonusDisputeId, setActiveBonusDisputeId] = useState<number | null>(null)
|
||||
const [bonusDisputeReason, setBonusDisputeReason] = useState('')
|
||||
const [isCreatingBonusDispute, setIsCreatingBonusDispute] = useState(false)
|
||||
|
||||
// Comment
|
||||
const [commentText, setCommentText] = useState('')
|
||||
const [isAddingComment, setIsAddingComment] = useState(false)
|
||||
@@ -38,10 +46,13 @@ export function AssignmentDetailPage() {
|
||||
useEffect(() => {
|
||||
loadAssignment()
|
||||
return () => {
|
||||
// Cleanup blob URL on unmount
|
||||
// Cleanup blob URLs on unmount
|
||||
if (proofMediaBlobUrl) {
|
||||
URL.revokeObjectURL(proofMediaBlobUrl)
|
||||
}
|
||||
Object.values(bonusProofMedia).forEach(media => {
|
||||
URL.revokeObjectURL(media.url)
|
||||
})
|
||||
}
|
||||
}, [id])
|
||||
|
||||
@@ -63,6 +74,22 @@ export function AssignmentDetailPage() {
|
||||
// Ignore error, media just won't show
|
||||
}
|
||||
}
|
||||
|
||||
// Load bonus proof media for playthrough
|
||||
if (data.is_playthrough && data.bonus_challenges) {
|
||||
const bonusMedia: Record<number, { url: string; type: 'image' | 'video' }> = {}
|
||||
for (const bonus of data.bonus_challenges) {
|
||||
if (bonus.proof_image_url) {
|
||||
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)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
setError(error.response?.data?.detail || 'Не удалось загрузить данные')
|
||||
@@ -88,6 +115,37 @@ export function AssignmentDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -215,39 +273,64 @@ export function AssignmentDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Challenge info */}
|
||||
{/* Challenge/Playthrough info */}
|
||||
<GlassCard variant="neon">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 border border-neon-500/20 flex items-center justify-center">
|
||||
<Gamepad2 className="w-7 h-7 text-neon-400" />
|
||||
<div className={`w-14 h-14 rounded-xl border flex items-center justify-center ${
|
||||
assignment.is_playthrough
|
||||
? 'bg-gradient-to-br from-accent-500/20 to-purple-500/20 border-accent-500/20'
|
||||
: 'bg-gradient-to-br from-neon-500/20 to-accent-500/20 border-neon-500/20'
|
||||
}`}>
|
||||
<Gamepad2 className={`w-7 h-7 ${assignment.is_playthrough ? 'text-accent-400' : 'text-neon-400'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">{assignment.challenge.game.title}</p>
|
||||
<h2 className="text-xl font-bold text-white">{assignment.challenge.title}</h2>
|
||||
<p className="text-gray-400 text-sm">
|
||||
{assignment.is_playthrough ? assignment.game?.title : assignment.challenge?.game.title}
|
||||
</p>
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
{assignment.is_playthrough ? 'Прохождение игры' : assignment.challenge?.title}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-3 py-1.5 rounded-full text-sm font-medium border flex items-center gap-1.5 ${status.color}`}>
|
||||
{status.icon}
|
||||
{status.text}
|
||||
</span>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{assignment.is_playthrough && (
|
||||
<span className="px-3 py-1 bg-accent-500/20 text-accent-400 rounded-full text-xs font-medium border border-accent-500/30">
|
||||
Прохождение
|
||||
</span>
|
||||
)}
|
||||
<span className={`px-3 py-1.5 rounded-full text-sm font-medium border flex items-center gap-1.5 ${status.color}`}>
|
||||
{status.icon}
|
||||
{status.text}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-300 mb-4">{assignment.challenge.description}</p>
|
||||
<p className="text-gray-300 mb-4">
|
||||
{assignment.is_playthrough
|
||||
? assignment.playthrough_info?.description
|
||||
: assignment.challenge?.description}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<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 flex items-center gap-1.5">
|
||||
<Trophy className="w-4 h-4" />
|
||||
+{assignment.challenge.points} очков
|
||||
+{assignment.is_playthrough
|
||||
? assignment.playthrough_info?.points
|
||||
: 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">
|
||||
{assignment.challenge.difficulty}
|
||||
</span>
|
||||
{assignment.challenge.estimated_time && (
|
||||
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600 flex items-center gap-1.5">
|
||||
<Clock className="w-4 h-4" />
|
||||
~{assignment.challenge.estimated_time} мин
|
||||
</span>
|
||||
{!assignment.is_playthrough && assignment.challenge && (
|
||||
<>
|
||||
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
|
||||
{assignment.challenge.difficulty}
|
||||
</span>
|
||||
{assignment.challenge.estimated_time && (
|
||||
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600 flex items-center gap-1.5">
|
||||
<Clock className="w-4 h-4" />
|
||||
~{assignment.challenge.estimated_time} мин
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -271,6 +354,185 @@ export function AssignmentDetailPage() {
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Bonus challenges for playthrough */}
|
||||
{assignment.is_playthrough && assignment.bonus_challenges && assignment.bonus_challenges.length > 0 && (
|
||||
<GlassCard>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
|
||||
<Trophy className="w-5 h-5 text-accent-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">Бонусные челленджи</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Выполнено: {assignment.bonus_challenges.filter((b: { status: string }) => b.status === 'completed').length} из {assignment.bonus_challenges.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{assignment.bonus_challenges.map((bonus) => (
|
||||
<div
|
||||
key={bonus.id}
|
||||
className={`p-4 rounded-xl border ${
|
||||
bonus.dispute ? 'bg-yellow-500/10 border-yellow-500/30' :
|
||||
bonus.status === 'completed'
|
||||
? 'bg-green-500/10 border-green-500/30'
|
||||
: 'bg-dark-700/50 border-dark-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{bonus.dispute ? (
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-400" />
|
||||
) : bonus.status === 'completed' ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
) : null}
|
||||
<span className="text-white font-medium">{bonus.challenge.title}</span>
|
||||
{bonus.dispute && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
bonus.dispute.status === 'open' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||
bonus.dispute.status === 'valid' ? 'bg-green-500/20 text-green-400' :
|
||||
'bg-red-500/20 text-red-400'
|
||||
}`}>
|
||||
{bonus.dispute.status === 'open' ? 'Оспаривается' :
|
||||
bonus.dispute.status === 'valid' ? 'Валидно' : 'Невалидно'}
|
||||
</span>
|
||||
)}
|
||||
</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) && (
|
||||
<div className="mt-2 text-xs space-y-2">
|
||||
{bonusProofMedia[bonus.id] && (
|
||||
<div className="rounded-lg overflow-hidden border border-dark-600 max-w-xs">
|
||||
{bonusProofMedia[bonus.id].type === 'video' ? (
|
||||
<video
|
||||
src={bonusProofMedia[bonus.id].url}
|
||||
controls
|
||||
className="w-full max-h-32 bg-dark-900"
|
||||
preload="metadata"
|
||||
/>
|
||||
) : (
|
||||
<a href={bonusProofMedia[bonus.id].url} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src={bonusProofMedia[bonus.id].url}
|
||||
alt="Proof"
|
||||
className="w-full h-auto max-h-32 object-cover hover:opacity-80 transition-opacity"
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{bonus.proof_url && (
|
||||
<a
|
||||
href={bonus.proof_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-neon-400 hover:underline flex items-center gap-1 break-all"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 shrink-0" />
|
||||
{bonus.proof_url}
|
||||
</a>
|
||||
)}
|
||||
{bonus.proof_comment && (
|
||||
<p className="text-gray-400">"{bonus.proof_comment}"</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bonus dispute form */}
|
||||
{activeBonusDisputeId === bonus.id && (
|
||||
<div className="mt-3 p-3 bg-red-500/10 rounded-lg border border-red-500/30">
|
||||
<textarea
|
||||
className="input w-full min-h-[80px] resize-none mb-2 text-sm"
|
||||
placeholder="Причина оспаривания (минимум 10 символов)..."
|
||||
value={bonusDisputeReason}
|
||||
onChange={(e) => setBonusDisputeReason(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 disabled:opacity-50"
|
||||
onClick={() => handleCreateBonusDispute(bonus.id)}
|
||||
disabled={bonusDisputeReason.trim().length < 10 || isCreatingBonusDispute}
|
||||
>
|
||||
{isCreatingBonusDispute ? 'Создание...' : 'Оспорить'}
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1.5 text-sm bg-dark-600 text-gray-300 rounded-lg hover:bg-dark-500"
|
||||
onClick={() => {
|
||||
setActiveBonusDisputeId(null)
|
||||
setBonusDisputeReason('')
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bonus dispute info */}
|
||||
{bonus.dispute && (
|
||||
<div className="mt-3 p-3 bg-yellow-500/10 rounded-lg border border-yellow-500/30">
|
||||
<p className="text-xs text-gray-400 mb-1">
|
||||
Оспорил: <span className="text-white">{bonus.dispute.raised_by.nickname}</span>
|
||||
</p>
|
||||
<p className="text-sm text-white mb-2">{bonus.dispute.reason}</p>
|
||||
|
||||
{bonus.dispute.status === 'open' && (
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<ThumbsUp className="w-3 h-3 text-green-400" />
|
||||
<span className="text-green-400 text-sm font-medium">{bonus.dispute.votes_valid}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ThumbsDown className="w-3 h-3 text-red-400" />
|
||||
<span className="text-red-400 text-sm font-medium">{bonus.dispute.votes_invalid}</span>
|
||||
</div>
|
||||
<div className="flex gap-1 ml-auto">
|
||||
<button
|
||||
className={`p-1.5 rounded ${bonus.dispute.my_vote === true ? 'bg-green-500/30' : 'bg-dark-600 hover:bg-dark-500'}`}
|
||||
onClick={() => handleBonusVote(bonus.dispute!.id, true)}
|
||||
disabled={isVoting}
|
||||
>
|
||||
<ThumbsUp className="w-3 h-3 text-green-400" />
|
||||
</button>
|
||||
<button
|
||||
className={`p-1.5 rounded ${bonus.dispute.my_vote === false ? 'bg-red-500/30' : 'bg-dark-600 hover:bg-dark-500'}`}
|
||||
onClick={() => handleBonusVote(bonus.dispute!.id, false)}
|
||||
disabled={isVoting}
|
||||
>
|
||||
<ThumbsDown className="w-3 h-3 text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right shrink-0 ml-3 flex flex-col items-end gap-2">
|
||||
{bonus.status === 'completed' ? (
|
||||
<span className="text-green-400 font-semibold">+{bonus.points_earned}</span>
|
||||
) : (
|
||||
<span className="text-gray-500">+{bonus.challenge.points}</span>
|
||||
)}
|
||||
{/* Dispute button for bonus */}
|
||||
{bonus.can_dispute && !bonus.dispute && activeBonusDisputeId !== bonus.id && (
|
||||
<button
|
||||
className="text-xs px-2 py-1 text-red-400 hover:bg-red-500/10 rounded flex items-center gap-1"
|
||||
onClick={() => setActiveBonusDisputeId(bonus.id)}
|
||||
>
|
||||
<Flag className="w-3 h-3" />
|
||||
Оспорить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Proof section */}
|
||||
<GlassCard>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { marathonsApi, gamesApi } from '@/api'
|
||||
import type { Marathon, Game, Challenge, ChallengePreview } from '@/types'
|
||||
import type { Marathon, Game, Challenge, ChallengePreview, GameType, ProofType } from '@/types'
|
||||
import { NeonButton, Input, GlassCard, StatsCard } from '@/components/ui'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useToast } from '@/store/toast'
|
||||
@@ -31,8 +31,25 @@ export function LobbyPage() {
|
||||
const [gameUrl, setGameUrl] = useState('')
|
||||
const [gameUrlError, setGameUrlError] = useState<string | null>(null)
|
||||
const [gameGenre, setGameGenre] = useState('')
|
||||
const [gameType, setGameType] = useState<GameType>('challenges')
|
||||
const [playthroughPoints, setPlaythroughPoints] = useState(50)
|
||||
const [playthroughDescription, setPlaythroughDescription] = useState('')
|
||||
const [playthroughProofType, setPlaythroughProofType] = useState<ProofType>('screenshot')
|
||||
const [playthroughProofHint, setPlaythroughProofHint] = useState('')
|
||||
const [isAddingGame, setIsAddingGame] = useState(false)
|
||||
|
||||
// Edit game modal
|
||||
const [editingGame, setEditingGame] = useState<Game | null>(null)
|
||||
const [editTitle, setEditTitle] = useState('')
|
||||
const [editUrl, setEditUrl] = useState('')
|
||||
const [editGenre, setEditGenre] = useState('')
|
||||
const [editGameType, setEditGameType] = useState<GameType>('challenges')
|
||||
const [editPlaythroughPoints, setEditPlaythroughPoints] = useState(50)
|
||||
const [editPlaythroughDescription, setEditPlaythroughDescription] = useState('')
|
||||
const [editPlaythroughProofType, setEditPlaythroughProofType] = useState<ProofType>('screenshot')
|
||||
const [editPlaythroughProofHint, setEditPlaythroughProofHint] = useState('')
|
||||
const [isEditingGame, setIsEditingGame] = useState(false)
|
||||
|
||||
const validateUrl = (url: string): boolean => {
|
||||
if (!url.trim()) return true // Empty is ok, will be caught by required check
|
||||
try {
|
||||
@@ -185,17 +202,38 @@ export function LobbyPage() {
|
||||
const handleAddGame = async () => {
|
||||
if (!id || !gameTitle.trim() || !gameUrl.trim() || !validateUrl(gameUrl)) return
|
||||
|
||||
// Validate playthrough fields
|
||||
if (gameType === 'playthrough') {
|
||||
if (!playthroughDescription.trim()) {
|
||||
toast.warning('Заполните описание прохождения')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setIsAddingGame(true)
|
||||
try {
|
||||
await gamesApi.create(parseInt(id), {
|
||||
title: gameTitle.trim(),
|
||||
download_url: gameUrl.trim(),
|
||||
genre: gameGenre.trim() || undefined,
|
||||
game_type: gameType,
|
||||
...(gameType === 'playthrough' && {
|
||||
playthrough_points: playthroughPoints,
|
||||
playthrough_description: playthroughDescription.trim(),
|
||||
playthrough_proof_type: playthroughProofType,
|
||||
playthrough_proof_hint: playthroughProofHint.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
// Reset form
|
||||
setGameTitle('')
|
||||
setGameUrl('')
|
||||
setGameUrlError(null)
|
||||
setGameGenre('')
|
||||
setGameType('challenges')
|
||||
setPlaythroughPoints(50)
|
||||
setPlaythroughDescription('')
|
||||
setPlaythroughProofType('screenshot')
|
||||
setPlaythroughProofHint('')
|
||||
setShowAddGame(false)
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
@@ -205,6 +243,56 @@ export function LobbyPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const openEditModal = (game: Game) => {
|
||||
setEditingGame(game)
|
||||
setEditTitle(game.title)
|
||||
setEditUrl(game.download_url)
|
||||
setEditGenre(game.genre || '')
|
||||
setEditGameType(game.game_type || 'challenges')
|
||||
setEditPlaythroughPoints(game.playthrough_points || 50)
|
||||
setEditPlaythroughDescription(game.playthrough_description || '')
|
||||
setEditPlaythroughProofType((game.playthrough_proof_type as ProofType) || 'screenshot')
|
||||
setEditPlaythroughProofHint(game.playthrough_proof_hint || '')
|
||||
}
|
||||
|
||||
const closeEditModal = () => {
|
||||
setEditingGame(null)
|
||||
}
|
||||
|
||||
const handleEditGame = async () => {
|
||||
if (!editingGame) return
|
||||
|
||||
// Validate playthrough fields
|
||||
if (editGameType === 'playthrough' && !editPlaythroughDescription.trim()) {
|
||||
toast.warning('Заполните описание прохождения')
|
||||
return
|
||||
}
|
||||
|
||||
setIsEditingGame(true)
|
||||
try {
|
||||
await gamesApi.update(editingGame.id, {
|
||||
title: editTitle.trim(),
|
||||
download_url: editUrl.trim(),
|
||||
genre: editGenre.trim() || undefined,
|
||||
game_type: editGameType,
|
||||
...(editGameType === 'playthrough' && {
|
||||
playthrough_points: editPlaythroughPoints,
|
||||
playthrough_description: editPlaythroughDescription.trim(),
|
||||
playthrough_proof_type: editPlaythroughProofType,
|
||||
playthrough_proof_hint: editPlaythroughProofHint.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
toast.success('Игра обновлена')
|
||||
closeEditModal()
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
console.error('Failed to update game:', error)
|
||||
toast.error('Не удалось обновить игру')
|
||||
} finally {
|
||||
setIsEditingGame(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteGame = async (gameId: number) => {
|
||||
const confirmed = await confirm({
|
||||
title: 'Удалить игру?',
|
||||
@@ -717,6 +805,11 @@ export function LobbyPage() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="font-semibold text-white">{game.title}</h4>
|
||||
{game.game_type === 'playthrough' && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-lg bg-accent-500/20 text-accent-400 border border-accent-500/30">
|
||||
Прохождение
|
||||
</span>
|
||||
)}
|
||||
{getStatusBadge(game.status)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 flex items-center gap-3 flex-wrap">
|
||||
@@ -759,6 +852,15 @@ export function LobbyPage() {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isOrganizer && (
|
||||
<button
|
||||
onClick={() => openEditModal(game)}
|
||||
className="p-2 rounded-lg text-neon-400 hover:bg-neon-500/10 transition-colors"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{(isOrganizer || game.proposed_by?.id === user?.id) && (
|
||||
<button
|
||||
onClick={() => handleDeleteGame(game.id)}
|
||||
@@ -1839,15 +1941,75 @@ export function LobbyPage() {
|
||||
value={gameGenre}
|
||||
onChange={(e) => setGameGenre(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Game type selector */}
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Тип игры</label>
|
||||
<select
|
||||
value={gameType}
|
||||
onChange={(e) => setGameType(e.target.value as GameType)}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="challenges">Челленджи — случайный челлендж при спине</option>
|
||||
<option value="playthrough">Прохождение — основная задача + бонусные челленджи</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Playthrough fields */}
|
||||
{gameType === 'playthrough' && (
|
||||
<div className="space-y-3 p-3 bg-accent-500/10 rounded-lg border border-accent-500/20">
|
||||
<p className="text-xs text-accent-400 font-medium">Настройки прохождения</p>
|
||||
<textarea
|
||||
placeholder="Что нужно сделать для прохождения (например: пройти игру до финальных титров)"
|
||||
value={playthroughDescription}
|
||||
onChange={(e) => setPlaythroughDescription(e.target.value)}
|
||||
className="input w-full resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Очки за прохождение</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={playthroughPoints}
|
||||
onChange={(e) => setPlaythroughPoints(parseInt(e.target.value) || 50)}
|
||||
min={1}
|
||||
max={500}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Тип пруфа</label>
|
||||
<select
|
||||
value={playthroughProofType}
|
||||
onChange={(e) => setPlaythroughProofType(e.target.value as ProofType)}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="screenshot">Скриншот</option>
|
||||
<option value="video">Видео</option>
|
||||
<option value="steam">Steam</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Подсказка для пруфа (необязательно)"
|
||||
value={playthroughProofHint}
|
||||
onChange={(e) => setPlaythroughProofHint(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
Все челленджи этой игры станут бонусными (опциональными)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<NeonButton
|
||||
onClick={handleAddGame}
|
||||
isLoading={isAddingGame}
|
||||
disabled={!gameTitle || !gameUrl || !!gameUrlError}
|
||||
disabled={!gameTitle || !gameUrl || !!gameUrlError || (gameType === 'playthrough' && !playthroughDescription)}
|
||||
>
|
||||
{isOrganizer ? 'Добавить' : 'Предложить'}
|
||||
</NeonButton>
|
||||
<NeonButton variant="outline" onClick={() => { setShowAddGame(false); setGameUrlError(null) }}>
|
||||
<NeonButton variant="outline" onClick={() => { setShowAddGame(false); setGameUrlError(null); setGameType('challenges') }}>
|
||||
Отмена
|
||||
</NeonButton>
|
||||
</div>
|
||||
@@ -1924,6 +2086,114 @@ export function LobbyPage() {
|
||||
onUpdate={setMarathon}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Game Modal */}
|
||||
{editingGame && (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="glass rounded-2xl border border-neon-500/20 w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-xl font-bold text-white">Редактировать игру</h3>
|
||||
<button
|
||||
onClick={closeEditModal}
|
||||
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
placeholder="Название игры"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Ссылка для скачивания"
|
||||
value={editUrl}
|
||||
onChange={(e) => setEditUrl(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Жанр (необязательно)"
|
||||
value={editGenre}
|
||||
onChange={(e) => setEditGenre(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Game type selector */}
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Тип игры</label>
|
||||
<select
|
||||
value={editGameType}
|
||||
onChange={(e) => setEditGameType(e.target.value as GameType)}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="challenges">Челленджи</option>
|
||||
<option value="playthrough">Прохождение</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Playthrough fields */}
|
||||
{editGameType === 'playthrough' && (
|
||||
<div className="space-y-3 p-3 bg-accent-500/10 rounded-lg border border-accent-500/20">
|
||||
<p className="text-xs text-accent-400 font-medium">Настройки прохождения</p>
|
||||
<textarea
|
||||
placeholder="Описание прохождения"
|
||||
value={editPlaythroughDescription}
|
||||
onChange={(e) => setEditPlaythroughDescription(e.target.value)}
|
||||
className="input w-full resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Очки</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editPlaythroughPoints}
|
||||
onChange={(e) => setEditPlaythroughPoints(parseInt(e.target.value) || 50)}
|
||||
min={1}
|
||||
max={500}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Тип пруфа</label>
|
||||
<select
|
||||
value={editPlaythroughProofType}
|
||||
onChange={(e) => setEditPlaythroughProofType(e.target.value as ProofType)}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="screenshot">Скриншот</option>
|
||||
<option value="video">Видео</option>
|
||||
<option value="steam">Steam</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Подсказка для пруфа (необязательно)"
|
||||
value={editPlaythroughProofHint}
|
||||
onChange={(e) => setEditPlaythroughProofHint(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<NeonButton
|
||||
className="flex-1"
|
||||
onClick={handleEditGame}
|
||||
isLoading={isEditingGame}
|
||||
disabled={!editTitle.trim() || !editUrl.trim() || (editGameType === 'playthrough' && !editPlaythroughDescription.trim())}
|
||||
icon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
Сохранить
|
||||
</NeonButton>
|
||||
<NeonButton variant="outline" onClick={closeEditModal}>
|
||||
Отмена
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { marathonsApi, eventsApi, challengesApi } from '@/api'
|
||||
import type { Marathon, ActiveEvent, Challenge } from '@/types'
|
||||
import type { Marathon, ActiveEvent, Challenge, MarathonDispute } from '@/types'
|
||||
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useToast } from '@/store/toast'
|
||||
@@ -13,7 +13,8 @@ import { MarathonSettingsModal } from '@/components/MarathonSettingsModal'
|
||||
import {
|
||||
Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2,
|
||||
Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag,
|
||||
Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2, Sparkles
|
||||
Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2, Sparkles,
|
||||
AlertTriangle, Clock, CheckCircle, XCircle, ThumbsUp, ThumbsDown, ExternalLink, User
|
||||
} from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
@@ -39,10 +40,23 @@ export function MarathonPage() {
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const activityFeedRef = useRef<ActivityFeedRef>(null)
|
||||
|
||||
// Disputes for organizers
|
||||
const [showDisputes, setShowDisputes] = useState(false)
|
||||
const [disputes, setDisputes] = useState<MarathonDispute[]>([])
|
||||
const [loadingDisputes, setLoadingDisputes] = useState(false)
|
||||
const [disputeFilter, setDisputeFilter] = useState<'open' | 'all'>('open')
|
||||
const [resolvingDisputeId, setResolvingDisputeId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadMarathon()
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
if (showDisputes) {
|
||||
loadDisputes()
|
||||
}
|
||||
}, [showDisputes, disputeFilter])
|
||||
|
||||
const loadMarathon = async () => {
|
||||
if (!id) return
|
||||
try {
|
||||
@@ -80,6 +94,57 @@ export function MarathonPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadDisputes = async () => {
|
||||
if (!id) return
|
||||
setLoadingDisputes(true)
|
||||
try {
|
||||
const data = await marathonsApi.listDisputes(parseInt(id), disputeFilter)
|
||||
setDisputes(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load disputes:', error)
|
||||
toast.error('Не удалось загрузить оспаривания')
|
||||
} finally {
|
||||
setLoadingDisputes(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResolveDispute = async (disputeId: number, isValid: boolean) => {
|
||||
if (!id) return
|
||||
setResolvingDisputeId(disputeId)
|
||||
try {
|
||||
await marathonsApi.resolveDispute(parseInt(id), disputeId, isValid)
|
||||
toast.success(isValid ? 'Пруф подтверждён' : 'Пруф отклонён')
|
||||
await loadDisputes()
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve dispute:', error)
|
||||
toast.error('Не удалось разрешить диспут')
|
||||
} finally {
|
||||
setResolvingDisputeId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDisputeDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
const getDisputeTimeRemaining = (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 getInviteLink = () => {
|
||||
if (!marathon) return ''
|
||||
return `${window.location.origin}/invite/${marathon.invite_code}`
|
||||
@@ -385,6 +450,196 @@ export function MarathonPage() {
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Disputes management for organizers */}
|
||||
{marathon.status === 'active' && isOrganizer && (
|
||||
<GlassCard>
|
||||
<button
|
||||
onClick={() => setShowDisputes(!showDisputes)}
|
||||
className="w-full flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-orange-500/20 flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h3 className="font-semibold text-white">Оспаривания</h3>
|
||||
<p className="text-sm text-gray-400">Проверьте спорные выполнения</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{disputes.filter(d => d.status === 'open').length > 0 && (
|
||||
<span className="px-2 py-1 bg-orange-500/20 text-orange-400 rounded text-xs font-medium">
|
||||
{disputes.filter(d => d.status === 'open').length} открыто
|
||||
</span>
|
||||
)}
|
||||
{showDisputes ? (
|
||||
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{showDisputes && (
|
||||
<div className="mt-6 pt-6 border-t border-dark-600">
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${
|
||||
disputeFilter === 'open'
|
||||
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
|
||||
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
|
||||
}`}
|
||||
onClick={() => setDisputeFilter('open')}
|
||||
>
|
||||
Открытые
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${
|
||||
disputeFilter === 'all'
|
||||
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30'
|
||||
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
|
||||
}`}
|
||||
onClick={() => setDisputeFilter('all')}
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loadingDisputes ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-accent-500" />
|
||||
</div>
|
||||
) : disputes.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-green-500/10 border border-green-500/30 flex items-center justify-center">
|
||||
<CheckCircle className="w-6 h-6 text-green-400" />
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">
|
||||
{disputeFilter === 'open' ? 'Нет открытых оспариваний' : 'Нет оспариваний'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{disputes.map((dispute) => (
|
||||
<div
|
||||
key={dispute.id}
|
||||
className={`p-4 bg-dark-700/50 rounded-xl border ${
|
||||
dispute.status === 'open' ? 'border-orange-500/30' : 'border-dark-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Challenge title */}
|
||||
<h4 className="text-white font-medium truncate mb-1">
|
||||
{dispute.challenge_title}
|
||||
</h4>
|
||||
{/* Participants */}
|
||||
<div className="flex flex-wrap gap-3 text-xs text-gray-400 mb-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
Автор: <span className="text-white">{dispute.participant_nickname}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Оспорил: <span className="text-white">{dispute.raised_by_nickname}</span>
|
||||
</span>
|
||||
</div>
|
||||
{/* Reason */}
|
||||
<p className="text-sm text-gray-300 mb-2 line-clamp-2">
|
||||
{dispute.reason}
|
||||
</p>
|
||||
{/* Votes & Time */}
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-0.5 text-green-400">
|
||||
<ThumbsUp className="w-3 h-3" />
|
||||
<span>{dispute.votes_valid}</span>
|
||||
</div>
|
||||
<span className="text-gray-600">/</span>
|
||||
<div className="flex items-center gap-0.5 text-red-400">
|
||||
<ThumbsDown className="w-3 h-3" />
|
||||
<span>{dispute.votes_invalid}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-gray-500">{formatDisputeDate(dispute.created_at)}</span>
|
||||
{dispute.status === 'open' && (
|
||||
<span className="text-orange-400 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{getDisputeTimeRemaining(dispute.expires_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Status & Actions */}
|
||||
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||
{dispute.status === 'open' ? (
|
||||
<span className="px-2 py-1 bg-yellow-500/20 text-yellow-400 rounded text-xs font-medium flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Открыт
|
||||
</span>
|
||||
) : dispute.status === 'valid' ? (
|
||||
<span className="px-2 py-1 bg-green-500/20 text-green-400 rounded text-xs font-medium flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Валидно
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 bg-red-500/20 text-red-400 rounded text-xs font-medium flex items-center gap-1">
|
||||
<XCircle className="w-3 h-3" />
|
||||
Невалидно
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Link to assignment */}
|
||||
{dispute.assignment_id && (
|
||||
<Link
|
||||
to={`/assignments/${dispute.assignment_id}`}
|
||||
className="text-xs text-accent-400 hover:text-accent-300 flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Открыть
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Resolution buttons */}
|
||||
{dispute.status === 'open' && (
|
||||
<div className="flex gap-1.5 mt-1">
|
||||
<NeonButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-green-500/50 text-green-400 hover:bg-green-500/10 !px-2 !py-1 text-xs"
|
||||
onClick={() => handleResolveDispute(dispute.id, true)}
|
||||
isLoading={resolvingDisputeId === dispute.id}
|
||||
disabled={resolvingDisputeId !== null}
|
||||
icon={<CheckCircle className="w-3 h-3" />}
|
||||
>
|
||||
Валидно
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/10 !px-2 !py-1 text-xs"
|
||||
onClick={() => handleResolveDispute(dispute.id, false)}
|
||||
isLoading={resolvingDisputeId === dispute.id}
|
||||
disabled={resolvingDisputeId !== null}
|
||||
icon={<XCircle className="w-3 h-3" />}
|
||||
>
|
||||
Невалидно
|
||||
</NeonButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Invite link */}
|
||||
{marathon.status !== 'finished' && (
|
||||
<GlassCard>
|
||||
|
||||
@@ -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" />}
|
||||
>
|
||||
Выполнено
|
||||
|
||||
312
frontend/src/pages/admin/AdminDisputesPage.tsx
Normal file
312
frontend/src/pages/admin/AdminDisputesPage.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { adminApi } from '@/api'
|
||||
import type { AdminDispute } from '@/types'
|
||||
import { GlassCard, NeonButton } from '@/components/ui'
|
||||
import { useToast } from '@/store/toast'
|
||||
import {
|
||||
AlertTriangle, Loader2, CheckCircle, XCircle, Clock,
|
||||
ThumbsUp, ThumbsDown, User, Trophy, ExternalLink
|
||||
} from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export function AdminDisputesPage() {
|
||||
const toast = useToast()
|
||||
const [disputes, setDisputes] = useState<AdminDispute[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [filter, setFilter] = useState<'pending' | 'open' | 'all'>('pending')
|
||||
const [resolvingId, setResolvingId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadDisputes()
|
||||
}, [filter])
|
||||
|
||||
const loadDisputes = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await adminApi.listDisputes(filter)
|
||||
setDisputes(data)
|
||||
} catch (err) {
|
||||
toast.error('Не удалось загрузить оспаривания')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResolve = async (disputeId: number, isValid: boolean) => {
|
||||
setResolvingId(disputeId)
|
||||
try {
|
||||
await adminApi.resolveDispute(disputeId, isValid)
|
||||
toast.success(isValid ? 'Пруф подтверждён' : 'Пруф отклонён')
|
||||
await loadDisputes()
|
||||
} catch (err) {
|
||||
toast.error('Не удалось разрешить диспут')
|
||||
} finally {
|
||||
setResolvingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
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 getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return (
|
||||
<span className="px-2 py-1 bg-blue-500/20 text-blue-400 rounded text-xs font-medium flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Голосование
|
||||
</span>
|
||||
)
|
||||
case 'pending_admin':
|
||||
return (
|
||||
<span className="px-2 py-1 bg-orange-500/20 text-orange-400 rounded text-xs font-medium flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Ожидает решения
|
||||
</span>
|
||||
)
|
||||
case 'valid':
|
||||
return (
|
||||
<span className="px-2 py-1 bg-green-500/20 text-green-400 rounded text-xs font-medium flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Валидно
|
||||
</span>
|
||||
)
|
||||
case 'invalid':
|
||||
return (
|
||||
<span className="px-2 py-1 bg-red-500/20 text-red-400 rounded text-xs font-medium flex items-center gap-1">
|
||||
<XCircle className="w-3 h-3" />
|
||||
Невалидно
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const pendingCount = disputes.filter(d => d.status === 'pending_admin').length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 to-orange-400">
|
||||
Оспаривания
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Управление диспутами и проверка пруфов
|
||||
</p>
|
||||
</div>
|
||||
{pendingCount > 0 && (
|
||||
<div className="px-4 py-2 bg-orange-500/20 border border-orange-500/30 rounded-xl">
|
||||
<span className="text-orange-400 font-semibold">{pendingCount}</span>
|
||||
<span className="text-gray-400 ml-2">ожида{pendingCount === 1 ? 'ет' : 'ют'} решения</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-all ${
|
||||
filter === 'pending'
|
||||
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
|
||||
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
|
||||
}`}
|
||||
onClick={() => setFilter('pending')}
|
||||
>
|
||||
Ожидают решения
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-all ${
|
||||
filter === 'open'
|
||||
? 'bg-blue-500/20 text-blue-400 border border-blue-500/30'
|
||||
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
|
||||
}`}
|
||||
onClick={() => setFilter('open')}
|
||||
>
|
||||
Голосование
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-all ${
|
||||
filter === 'all'
|
||||
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30'
|
||||
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
|
||||
}`}
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-accent-500" />
|
||||
</div>
|
||||
) : disputes.length === 0 ? (
|
||||
<GlassCard className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-green-500/10 border border-green-500/30 flex items-center justify-center">
|
||||
<CheckCircle className="w-8 h-8 text-green-400" />
|
||||
</div>
|
||||
<p className="text-gray-400">
|
||||
{filter === 'pending' ? 'Нет оспариваний, ожидающих решения' :
|
||||
filter === 'open' ? 'Нет оспариваний в стадии голосования' :
|
||||
'Нет оспариваний'}
|
||||
</p>
|
||||
</GlassCard>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{disputes.map((dispute) => (
|
||||
<GlassCard
|
||||
key={dispute.id}
|
||||
className={
|
||||
dispute.status === 'pending_admin' ? 'border-orange-500/30' :
|
||||
dispute.status === 'open' ? 'border-blue-500/30' : ''
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{/* Left side - Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center shrink-0">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-400" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-white font-semibold truncate">
|
||||
{dispute.challenge_title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Trophy className="w-3 h-3" />
|
||||
<span className="truncate">{dispute.marathon_title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Participants */}
|
||||
<div className="flex flex-wrap gap-4 mb-3 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<User className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-400">Автор:</span>
|
||||
<span className="text-white">{dispute.participant_nickname}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-400">Оспорил:</span>
|
||||
<span className="text-white">{dispute.raised_by_nickname}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reason */}
|
||||
<div className="p-3 bg-dark-700/50 rounded-lg border border-dark-600 mb-3">
|
||||
<p className="text-sm text-gray-400 mb-1">Причина:</p>
|
||||
<p className="text-white text-sm">{dispute.reason}</p>
|
||||
</div>
|
||||
|
||||
{/* Votes & Time */}
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-green-400">
|
||||
<ThumbsUp className="w-4 h-4" />
|
||||
<span className="font-medium">{dispute.votes_valid}</span>
|
||||
</div>
|
||||
<span className="text-gray-600">/</span>
|
||||
<div className="flex items-center gap-1 text-red-400">
|
||||
<ThumbsDown className="w-4 h-4" />
|
||||
<span className="font-medium">{dispute.votes_invalid}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-gray-600">•</span>
|
||||
<span className="text-gray-400">{formatDate(dispute.created_at)}</span>
|
||||
{dispute.status === 'open' && (
|
||||
<>
|
||||
<span className="text-gray-600">•</span>
|
||||
<span className="text-yellow-400 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{getTimeRemaining(dispute.expires_at)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Status & Actions */}
|
||||
<div className="flex flex-col items-end gap-3 shrink-0">
|
||||
{getStatusBadge(dispute.status)}
|
||||
|
||||
{/* Link to assignment */}
|
||||
{dispute.assignment_id && (
|
||||
<Link
|
||||
to={`/assignments/${dispute.assignment_id}`}
|
||||
className="text-xs text-accent-400 hover:text-accent-300 flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Открыть
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Resolution buttons - show for open and pending_admin */}
|
||||
{(dispute.status === 'open' || dispute.status === 'pending_admin') && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Vote recommendation for pending disputes */}
|
||||
{dispute.status === 'pending_admin' && (
|
||||
<div className="text-xs text-gray-400 text-right mb-1">
|
||||
Рекомендация: {dispute.votes_invalid > dispute.votes_valid ? (
|
||||
<span className="text-red-400">невалидно</span>
|
||||
) : (
|
||||
<span className="text-green-400">валидно</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<NeonButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-green-500/50 text-green-400 hover:bg-green-500/10"
|
||||
onClick={() => handleResolve(dispute.id, true)}
|
||||
isLoading={resolvingId === dispute.id}
|
||||
disabled={resolvingId !== null}
|
||||
icon={<CheckCircle className="w-4 h-4" />}
|
||||
>
|
||||
Валидно
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/10"
|
||||
onClick={() => handleResolve(dispute.id, false)}
|
||||
isLoading={resolvingId === dispute.id}
|
||||
disabled={resolvingId !== null}
|
||||
icon={<XCircle className="w-4 h-4" />}
|
||||
>
|
||||
Невалидно
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,13 +12,15 @@ import {
|
||||
Shield,
|
||||
MessageCircle,
|
||||
Sparkles,
|
||||
Lock
|
||||
Lock,
|
||||
AlertTriangle
|
||||
} from 'lucide-react'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/admin', icon: LayoutDashboard, label: 'Дашборд', end: true },
|
||||
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
|
||||
{ to: '/admin/marathons', icon: Trophy, label: 'Марафоны' },
|
||||
{ to: '/admin/disputes', icon: AlertTriangle, label: 'Оспаривания' },
|
||||
{ to: '/admin/logs', icon: ScrollText, label: 'Логи' },
|
||||
{ to: '/admin/broadcast', icon: Send, label: 'Рассылка' },
|
||||
{ to: '/admin/content', icon: FileText, label: 'Контент' },
|
||||
|
||||
@@ -2,6 +2,7 @@ export { AdminLayout } from './AdminLayout'
|
||||
export { AdminDashboardPage } from './AdminDashboardPage'
|
||||
export { AdminUsersPage } from './AdminUsersPage'
|
||||
export { AdminMarathonsPage } from './AdminMarathonsPage'
|
||||
export { AdminDisputesPage } from './AdminDisputesPage'
|
||||
export { AdminLogsPage } from './AdminLogsPage'
|
||||
export { AdminBroadcastPage } from './AdminBroadcastPage'
|
||||
export { AdminContentPage } from './AdminContentPage'
|
||||
|
||||
@@ -122,6 +122,14 @@ export interface LeaderboardEntry {
|
||||
|
||||
// Game types
|
||||
export type GameStatus = 'pending' | 'approved' | 'rejected'
|
||||
export type GameType = 'challenges' | 'playthrough'
|
||||
|
||||
export interface PlaythroughInfo {
|
||||
description: string
|
||||
points: number
|
||||
proof_type: ProofType
|
||||
proof_hint: string | null
|
||||
}
|
||||
|
||||
export interface Game {
|
||||
id: number
|
||||
@@ -134,12 +142,24 @@ export interface Game {
|
||||
approved_by: User | null
|
||||
challenges_count: number
|
||||
created_at: string
|
||||
// Game type fields
|
||||
game_type: GameType
|
||||
playthrough_points: number | null
|
||||
playthrough_description: string | null
|
||||
playthrough_proof_type: ProofType | null
|
||||
playthrough_proof_hint: string | null
|
||||
}
|
||||
|
||||
export interface GameShort {
|
||||
id: number
|
||||
title: string
|
||||
cover_url: string | null
|
||||
game_type?: GameType
|
||||
}
|
||||
|
||||
export interface AvailableGamesCount {
|
||||
available: number
|
||||
total: number
|
||||
}
|
||||
|
||||
// Challenge types
|
||||
@@ -199,10 +219,27 @@ export interface ChallengesPreviewResponse {
|
||||
|
||||
// Assignment types
|
||||
export type AssignmentStatus = 'active' | 'completed' | 'dropped' | 'returned'
|
||||
export type BonusAssignmentStatus = 'pending' | 'completed'
|
||||
|
||||
export interface BonusAssignment {
|
||||
id: number
|
||||
challenge: Challenge
|
||||
status: BonusAssignmentStatus
|
||||
proof_url: string | null
|
||||
proof_image_url: string | null
|
||||
proof_comment: string | null
|
||||
points_earned: number
|
||||
completed_at: string | null
|
||||
can_dispute?: boolean
|
||||
dispute?: Dispute | null
|
||||
}
|
||||
|
||||
export interface Assignment {
|
||||
id: number
|
||||
challenge: Challenge
|
||||
challenge: Challenge | null // null for playthrough
|
||||
game?: GameShort // For playthrough
|
||||
is_playthrough?: boolean
|
||||
playthrough_info?: PlaythroughInfo // For playthrough
|
||||
status: AssignmentStatus
|
||||
proof_url: string | null
|
||||
proof_comment: string | null
|
||||
@@ -211,12 +248,16 @@ export interface Assignment {
|
||||
started_at: string
|
||||
completed_at: string | null
|
||||
drop_penalty: number
|
||||
bonus_challenges?: BonusAssignment[] // For playthrough
|
||||
}
|
||||
|
||||
export interface SpinResult {
|
||||
assignment_id: number
|
||||
game: Game
|
||||
challenge: Challenge
|
||||
challenge: Challenge | null // null for playthrough
|
||||
is_playthrough?: boolean
|
||||
playthrough_info?: PlaythroughInfo // For playthrough
|
||||
bonus_challenges?: Challenge[] // Available bonus challenges for playthrough
|
||||
can_drop: boolean
|
||||
drop_penalty: number
|
||||
}
|
||||
@@ -508,8 +549,42 @@ export interface DashboardStats {
|
||||
recent_logs: AdminLog[]
|
||||
}
|
||||
|
||||
// Admin dispute
|
||||
export interface AdminDispute {
|
||||
id: number
|
||||
assignment_id: number | null
|
||||
bonus_assignment_id: number | null
|
||||
marathon_id: number
|
||||
marathon_title: string
|
||||
challenge_title: string
|
||||
participant_nickname: string
|
||||
raised_by_nickname: string
|
||||
reason: string
|
||||
status: DisputeStatus
|
||||
votes_valid: number
|
||||
votes_invalid: number
|
||||
created_at: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
// Marathon organizer dispute
|
||||
export interface MarathonDispute {
|
||||
id: number
|
||||
assignment_id: number | null
|
||||
bonus_assignment_id: number | null
|
||||
challenge_title: string
|
||||
participant_nickname: string
|
||||
raised_by_nickname: string
|
||||
reason: string
|
||||
status: DisputeStatus
|
||||
votes_valid: number
|
||||
votes_invalid: number
|
||||
created_at: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
// Dispute types
|
||||
export type DisputeStatus = 'open' | 'valid' | 'invalid'
|
||||
export type DisputeStatus = 'open' | 'pending_admin' | 'valid' | 'invalid'
|
||||
|
||||
export interface DisputeComment {
|
||||
id: number
|
||||
@@ -541,7 +616,10 @@ export interface Dispute {
|
||||
|
||||
export interface AssignmentDetail {
|
||||
id: number
|
||||
challenge: Challenge
|
||||
challenge: Challenge | null // null for playthrough
|
||||
game?: GameShort // for playthrough
|
||||
is_playthrough: boolean
|
||||
playthrough_info?: PlaythroughInfo // for playthrough
|
||||
participant: User
|
||||
status: AssignmentStatus
|
||||
proof_url: string | null
|
||||
@@ -553,11 +631,16 @@ export interface AssignmentDetail {
|
||||
completed_at: string | null
|
||||
can_dispute: boolean
|
||||
dispute: Dispute | null
|
||||
bonus_challenges?: BonusAssignment[] // for playthrough
|
||||
}
|
||||
|
||||
export interface ReturnedAssignment {
|
||||
id: number
|
||||
challenge: Challenge
|
||||
challenge: Challenge | null // For challenge assignments
|
||||
is_playthrough: boolean
|
||||
game_id: number | null // For playthrough assignments
|
||||
game_title: string | null
|
||||
game_cover_url: string | null
|
||||
original_completed_at: string
|
||||
dispute_reason: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user