Redesign p1

This commit is contained in:
2025-12-17 02:03:33 +07:00
parent 11f7b59471
commit 332491454d
29 changed files with 5137 additions and 2587 deletions

View File

@@ -2,13 +2,13 @@ import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { assignmentsApi } from '@/api'
import type { AssignmentDetail } from '@/types'
import { Card, CardContent, Button } from '@/components/ui'
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
Send, Flag, Gamepad2, Zap, Trophy
} from 'lucide-react'
export function AssignmentDetailPage() {
@@ -142,373 +142,412 @@ export function AssignmentDetailPage() {
return `${hours}ч ${minutes}м`
}
const getStatusBadge = (status: string) => {
const getStatusConfig = (status: string) => {
switch (status) {
case 'completed':
return (
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full text-sm flex items-center gap-1">
<CheckCircle className="w-4 h-4" /> Выполнено
</span>
)
return {
color: 'bg-green-500/20 text-green-400 border-green-500/30',
icon: <CheckCircle className="w-4 h-4" />,
text: 'Выполнено',
}
case 'dropped':
return (
<span className="px-3 py-1 bg-red-500/20 text-red-400 rounded-full text-sm flex items-center gap-1">
<XCircle className="w-4 h-4" /> Пропущено
</span>
)
return {
color: 'bg-red-500/20 text-red-400 border-red-500/30',
icon: <XCircle className="w-4 h-4" />,
text: 'Пропущено',
}
case 'returned':
return (
<span className="px-3 py-1 bg-orange-500/20 text-orange-400 rounded-full text-sm flex items-center gap-1">
<AlertTriangle className="w-4 h-4" /> Возвращено
</span>
)
return {
color: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
icon: <AlertTriangle className="w-4 h-4" />,
text: 'Возвращено',
}
default:
return (
<span className="px-3 py-1 bg-blue-500/20 text-blue-400 rounded-full text-sm">
Активно
</span>
)
return {
color: 'bg-neon-500/20 text-neon-400 border-neon-500/30',
icon: <Zap className="w-4 h-4" />,
text: 'Активно',
}
}
}
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
<div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка...</p>
</div>
)
}
if (error || !assignment) {
return (
<div className="max-w-2xl mx-auto text-center py-12">
<p className="text-red-400 mb-4">{error || 'Задание не найдено'}</p>
<Button onClick={() => navigate(-1)}>Назад</Button>
<div className="max-w-2xl mx-auto">
<GlassCard className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-red-500/10 border border-red-500/30 flex items-center justify-center">
<AlertTriangle className="w-8 h-8 text-red-400" />
</div>
<p className="text-gray-400 mb-6">{error || 'Задание не найдено'}</p>
<NeonButton variant="outline" onClick={() => navigate(-1)}>
Назад
</NeonButton>
</GlassCard>
</div>
)
}
const dispute = assignment.dispute
const status = getStatusConfig(assignment.status)
return (
<div className="max-w-2xl mx-auto">
<div className="max-w-2xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<button onClick={() => navigate(-1)} className="text-gray-400 hover:text-white">
<ArrowLeft className="w-6 h-6" />
<div className="flex items-center gap-4">
<button
onClick={() => navigate(-1)}
className="w-10 h-10 rounded-xl bg-dark-700 border border-dark-600 flex items-center justify-center text-gray-400 hover:text-white hover:border-neon-500/30 transition-all"
>
<ArrowLeft className="w-5 h-5" />
</button>
<h1 className="text-xl font-bold text-white">Детали выполнения</h1>
<div>
<h1 className="text-xl font-bold text-white">Детали выполнения</h1>
<p className="text-sm text-gray-400">Просмотр доказательства</p>
</div>
</div>
{/* Challenge info */}
<Card className="mb-6">
<CardContent>
<div className="flex items-start justify-between mb-4">
<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>
<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>
</div>
{getStatusBadge(assignment.status)}
</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>
<p className="text-gray-300 mb-4">{assignment.challenge.description}</p>
<p className="text-gray-300 mb-4">{assignment.challenge.description}</p>
<div className="flex flex-wrap gap-2 mb-4">
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full text-sm">
+{assignment.challenge.points} очков
<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} очков
</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>
<span className="px-3 py-1 bg-gray-700 text-gray-300 rounded-full text-sm">
{assignment.challenge.difficulty}
</span>
{assignment.challenge.estimated_time && (
<span className="px-3 py-1 bg-gray-700 text-gray-300 rounded-full text-sm">
~{assignment.challenge.estimated_time} мин
</span>
)}
</div>
)}
</div>
<div className="text-sm text-gray-400 space-y-1">
<div className="pt-4 border-t border-dark-600 text-sm text-gray-400 space-y-1">
<p>
<span className="text-gray-500">Выполнил:</span>{' '}
<span className="text-white">{assignment.participant.nickname}</span>
</p>
{assignment.completed_at && (
<p>
<strong>Выполнил:</strong> {assignment.participant.nickname}
<span className="text-gray-500">Дата:</span>{' '}
<span className="text-white">{formatDate(assignment.completed_at)}</span>
</p>
{assignment.completed_at && (
<p>
<strong>Дата:</strong> {formatDate(assignment.completed_at)}
</p>
)}
{assignment.points_earned > 0 && (
<p>
<strong>Получено очков:</strong> {assignment.points_earned}
</p>
)}
</div>
</CardContent>
</Card>
)}
{assignment.points_earned > 0 && (
<p>
<span className="text-gray-500">Получено очков:</span>{' '}
<span className="text-neon-400 font-semibold">{assignment.points_earned}</span>
</p>
)}
</div>
</GlassCard>
{/* Proof section */}
<Card className="mb-6">
<CardContent>
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
<Image className="w-5 h-5" />
Доказательство
</h3>
<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">
<Image className="w-5 h-5 text-accent-400" />
</div>
<div>
<h3 className="font-semibold text-white">Доказательство</h3>
<p className="text-sm text-gray-400">Пруф выполнения задания</p>
</div>
</div>
{/* Proof media (image or video) */}
{assignment.proof_image_url && (
<div className="mb-4">
{proofMediaBlobUrl ? (
proofMediaType === 'video' ? (
<video
src={proofMediaBlobUrl}
controls
className="w-full rounded-lg max-h-96 bg-gray-900"
preload="metadata"
/>
) : (
<img
src={proofMediaBlobUrl}
alt="Proof"
className="w-full rounded-lg max-h-96 object-contain bg-gray-900"
/>
)
{/* Proof media (image or video) */}
{assignment.proof_image_url && (
<div className="mb-4 rounded-xl overflow-hidden border border-dark-600">
{proofMediaBlobUrl ? (
proofMediaType === 'video' ? (
<video
src={proofMediaBlobUrl}
controls
className="w-full max-h-96 bg-dark-900"
preload="metadata"
/>
) : (
<div className="w-full h-48 bg-gray-900 rounded-lg flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-gray-500" />
</div>
)}
</div>
)}
<img
src={proofMediaBlobUrl}
alt="Proof"
className="w-full max-h-96 object-contain bg-dark-900"
/>
)
) : (
<div className="w-full h-48 bg-dark-900 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-gray-500" />
</div>
)}
</div>
)}
{/* Proof URL */}
{assignment.proof_url && (
<div className="mb-4">
<a
href={assignment.proof_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-primary-400 hover:text-primary-300"
>
<ExternalLink className="w-4 h-4" />
{assignment.proof_url}
</a>
</div>
)}
{/* Proof URL */}
{assignment.proof_url && (
<div className="mb-4">
<a
href={assignment.proof_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-neon-400 hover:text-neon-300 transition-colors"
>
<ExternalLink className="w-4 h-4" />
{assignment.proof_url}
</a>
</div>
)}
{/* Proof comment */}
{assignment.proof_comment && (
<div className="p-3 bg-gray-900 rounded-lg">
<p className="text-sm text-gray-400 mb-1">Комментарий:</p>
<p className="text-white">{assignment.proof_comment}</p>
</div>
)}
{/* Proof comment */}
{assignment.proof_comment && (
<div className="p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<p className="text-sm text-gray-400 mb-1">Комментарий:</p>
<p className="text-white">{assignment.proof_comment}</p>
</div>
)}
{!assignment.proof_image_url && !assignment.proof_url && (
<p className="text-gray-500 text-center py-4">Пруф не предоставлен</p>
)}
</CardContent>
</Card>
{!assignment.proof_image_url && !assignment.proof_url && (
<div className="text-center py-8">
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-dark-700 flex items-center justify-center">
<Image className="w-6 h-6 text-gray-600" />
</div>
<p className="text-gray-500">Пруф не предоставлен</p>
</div>
)}
</GlassCard>
{/* Dispute button */}
{assignment.can_dispute && !dispute && !showDisputeForm && (
<Button
variant="danger"
className="w-full mb-6"
<NeonButton
variant="outline"
className="w-full border-red-500/50 text-red-400 hover:bg-red-500/10"
onClick={() => setShowDisputeForm(true)}
icon={<Flag className="w-4 h-4" />}
>
<Flag className="w-4 h-4 mr-2" />
Оспорить выполнение
</Button>
</NeonButton>
)}
{/* Dispute creation form */}
{showDisputeForm && !dispute && (
<Card className="mb-6 border-red-500/50">
<CardContent>
<h3 className="text-lg font-bold text-red-400 mb-4 flex items-center gap-2">
<AlertTriangle className="w-5 h-5" />
Оспорить выполнение
</h3>
<p className="text-gray-400 text-sm mb-4">
Опишите причину оспаривания. После создания у участников будет 24 часа для голосования.
</p>
<textarea
className="input w-full min-h-[100px] resize-none mb-4"
placeholder="Причина оспаривания (минимум 10 символов)..."
value={disputeReason}
onChange={(e) => setDisputeReason(e.target.value)}
/>
<div className="flex gap-3">
<Button
variant="danger"
className="flex-1"
onClick={handleCreateDispute}
isLoading={isCreatingDispute}
disabled={disputeReason.trim().length < 10}
>
Оспорить
</Button>
<Button
variant="secondary"
onClick={() => {
setShowDisputeForm(false)
setDisputeReason('')
}}
>
Отмена
</Button>
<GlassCard className="border-red-500/30">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-red-500/20 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-red-400" />
</div>
</CardContent>
</Card>
<div>
<h3 className="font-semibold text-red-400">Оспорить выполнение</h3>
<p className="text-sm text-gray-400">У участников будет 24 часа для голосования</p>
</div>
</div>
<textarea
className="input w-full min-h-[100px] resize-none mb-4"
placeholder="Причина оспаривания (минимум 10 символов)..."
value={disputeReason}
onChange={(e) => setDisputeReason(e.target.value)}
/>
<div className="flex gap-3">
<NeonButton
className="flex-1 border-red-500/50 bg-red-500/10 hover:bg-red-500/20 text-red-400"
onClick={handleCreateDispute}
isLoading={isCreatingDispute}
disabled={disputeReason.trim().length < 10}
>
Оспорить
</NeonButton>
<NeonButton
variant="outline"
onClick={() => {
setShowDisputeForm(false)
setDisputeReason('')
}}
>
Отмена
</NeonButton>
</div>
</GlassCard>
)}
{/* Dispute section */}
{dispute && (
<Card className={`mb-6 ${dispute.status === 'open' ? 'border-yellow-500/50' : ''}`}>
<CardContent>
<div className="flex items-start justify-between mb-4">
<h3 className="text-lg font-bold text-yellow-400 flex items-center gap-2">
<AlertTriangle className="w-5 h-5" />
Оспаривание
</h3>
<GlassCard className={dispute.status === 'open' ? 'border-yellow-500/30' : ''}>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-yellow-400" />
</div>
<h3 className="font-semibold text-yellow-400">Оспаривание</h3>
</div>
{dispute.status === 'open' ? (
<span className="px-3 py-1 bg-yellow-500/20 text-yellow-400 rounded-full text-sm flex items-center gap-1">
<Clock className="w-4 h-4" />
{getTimeRemaining(dispute.expires_at)}
</span>
) : dispute.status === 'valid' ? (
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full text-sm flex items-center gap-1">
<CheckCircle className="w-4 h-4" />
Пруф валиден
</span>
) : (
<span className="px-3 py-1 bg-red-500/20 text-red-400 rounded-full text-sm flex items-center gap-1">
<XCircle className="w-4 h-4" />
Пруф невалиден
</span>
{dispute.status === 'open' ? (
<span className="px-3 py-1.5 bg-yellow-500/20 text-yellow-400 rounded-lg text-sm font-medium border border-yellow-500/30 flex items-center gap-1.5">
<Clock className="w-4 h-4" />
{getTimeRemaining(dispute.expires_at)}
</span>
) : dispute.status === 'valid' ? (
<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">
<CheckCircle className="w-4 h-4" />
Пруф валиден
</span>
) : (
<span className="px-3 py-1.5 bg-red-500/20 text-red-400 rounded-lg text-sm font-medium border border-red-500/30 flex items-center gap-1.5">
<XCircle className="w-4 h-4" />
Пруф невалиден
</span>
)}
</div>
<div className="mb-4 text-sm text-gray-400">
<p>
Открыл: <span className="text-white">{dispute.raised_by.nickname}</span>
</p>
<p>
Дата: <span className="text-white">{formatDate(dispute.created_at)}</span>
</p>
</div>
<div className="p-4 bg-dark-700/50 rounded-xl border border-dark-600 mb-4">
<p className="text-sm text-gray-400 mb-1">Причина:</p>
<p className="text-white">{dispute.reason}</p>
</div>
{/* Voting section */}
{dispute.status === 'open' && (
<div className="mb-6 p-4 bg-dark-700/30 rounded-xl border border-dark-600">
<h4 className="text-sm font-semibold text-white mb-4">Голосование</h4>
<div className="flex items-center gap-6 mb-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-green-500/20 flex items-center justify-center">
<ThumbsUp className="w-4 h-4 text-green-400" />
</div>
<span className="text-green-400 font-bold text-lg">{dispute.votes_valid}</span>
<span className="text-gray-500 text-sm">валидно</span>
</div>
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-red-500/20 flex items-center justify-center">
<ThumbsDown className="w-4 h-4 text-red-400" />
</div>
<span className="text-red-400 font-bold text-lg">{dispute.votes_invalid}</span>
<span className="text-gray-500 text-sm">невалидно</span>
</div>
</div>
<div className="flex gap-3">
<NeonButton
className={`flex-1 ${dispute.my_vote === true ? 'bg-green-500/20 border-green-500/50 text-green-400' : ''}`}
variant="outline"
onClick={() => handleVote(true)}
isLoading={isVoting}
disabled={isVoting}
icon={<ThumbsUp className="w-4 h-4" />}
>
Валидно
</NeonButton>
<NeonButton
className={`flex-1 ${dispute.my_vote === false ? 'bg-red-500/20 border-red-500/50 text-red-400' : ''}`}
variant="outline"
onClick={() => handleVote(false)}
isLoading={isVoting}
disabled={isVoting}
icon={<ThumbsDown className="w-4 h-4" />}
>
Невалидно
</NeonButton>
</div>
{dispute.my_vote !== null && (
<p className="text-sm text-gray-500 mt-3 text-center">
Вы проголосовали: <span className={dispute.my_vote ? 'text-green-400' : 'text-red-400'}>
{dispute.my_vote ? 'валидно' : 'невалидно'}
</span>
</p>
)}
</div>
)}
<div className="mb-4">
<p className="text-sm text-gray-400">
Открыл: <span className="text-white">{dispute.raised_by.nickname}</span>
</p>
<p className="text-sm text-gray-400">
Дата: <span className="text-white">{formatDate(dispute.created_at)}</span>
</p>
{/* Comments section */}
<div>
<div className="flex items-center gap-2 mb-4">
<MessageSquare className="w-4 h-4 text-gray-400" />
<h4 className="text-sm font-semibold text-white">
Обсуждение ({dispute.comments.length})
</h4>
</div>
<div className="p-3 bg-gray-900 rounded-lg mb-4">
<p className="text-sm text-gray-400 mb-1">Причина:</p>
<p className="text-white">{dispute.reason}</p>
</div>
{/* Voting section */}
{dispute.status === 'open' && (
<div className="mb-4">
<h4 className="text-sm font-medium text-gray-300 mb-3">Голосование</h4>
<div className="flex items-center gap-4 mb-3">
<div className="flex items-center gap-2">
<ThumbsUp className="w-5 h-5 text-green-500" />
<span className="text-green-400 font-medium">{dispute.votes_valid}</span>
<span className="text-gray-500 text-sm">валидно</span>
{dispute.comments.length > 0 && (
<div className="space-y-3 mb-4 max-h-60 overflow-y-auto custom-scrollbar">
{dispute.comments.map((comment) => (
<div key={comment.id} className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-1">
<span className={`font-medium ${comment.user.id === user?.id ? 'text-neon-400' : 'text-white'}`}>
{comment.user.nickname}
{comment.user.id === user?.id && ' (Вы)'}
</span>
<span className="text-xs text-gray-500">
{formatDate(comment.created_at)}
</span>
</div>
<p className="text-gray-300 text-sm">{comment.text}</p>
</div>
<div className="flex items-center gap-2">
<ThumbsDown className="w-5 h-5 text-red-500" />
<span className="text-red-400 font-medium">{dispute.votes_invalid}</span>
<span className="text-gray-500 text-sm">невалидно</span>
</div>
</div>
<div className="flex gap-3">
<Button
variant={dispute.my_vote === true ? 'primary' : 'secondary'}
className="flex-1"
onClick={() => handleVote(true)}
isLoading={isVoting}
disabled={isVoting}
>
<ThumbsUp className="w-4 h-4 mr-2" />
Валидно
</Button>
<Button
variant={dispute.my_vote === false ? 'danger' : 'secondary'}
className="flex-1"
onClick={() => handleVote(false)}
isLoading={isVoting}
disabled={isVoting}
>
<ThumbsDown className="w-4 h-4 mr-2" />
Невалидно
</Button>
</div>
{dispute.my_vote !== null && (
<p className="text-sm text-gray-500 mt-2 text-center">
Вы проголосовали: {dispute.my_vote ? 'валидно' : 'невалидно'}
</p>
)}
))}
</div>
)}
{/* Comments section */}
<div>
<h4 className="text-sm font-medium text-gray-300 mb-3 flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
Обсуждение ({dispute.comments.length})
</h4>
{dispute.comments.length > 0 && (
<div className="space-y-3 mb-4 max-h-60 overflow-y-auto">
{dispute.comments.map((comment) => (
<div key={comment.id} className="p-3 bg-gray-900 rounded-lg">
<div className="flex items-center justify-between mb-1">
<span className={`font-medium ${comment.user.id === user?.id ? 'text-primary-400' : 'text-white'}`}>
{comment.user.nickname}
{comment.user.id === user?.id && ' (Вы)'}
</span>
<span className="text-xs text-gray-500">
{formatDate(comment.created_at)}
</span>
</div>
<p className="text-gray-300 text-sm">{comment.text}</p>
</div>
))}
</div>
)}
{/* Add comment form */}
{dispute.status === 'open' && (
<div className="flex gap-2">
<input
type="text"
className="input flex-1"
placeholder="Написать комментарий..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleAddComment()
}
}}
/>
<Button
onClick={handleAddComment}
isLoading={isAddingComment}
disabled={!commentText.trim()}
>
<Send className="w-4 h-4" />
</Button>
</div>
)}
</div>
</CardContent>
</Card>
{/* Add comment form */}
{dispute.status === 'open' && (
<div className="flex gap-2">
<input
type="text"
className="input flex-1"
placeholder="Написать комментарий..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleAddComment()
}
}}
/>
<NeonButton
onClick={handleAddComment}
isLoading={isAddingComment}
disabled={!commentText.trim()}
icon={<Send className="w-4 h-4" />}
/>
</div>
)}
</div>
</GlassCard>
)}
</div>
)

View File

@@ -4,8 +4,8 @@ import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { marathonsApi } from '@/api'
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui'
import { Globe, Lock, Users, UserCog, ArrowLeft } from 'lucide-react'
import { NeonButton, Input, GlassCard } from '@/components/ui'
import { Globe, Lock, Users, UserCog, ArrowLeft, Gamepad2, AlertCircle, Sparkles, Calendar, Clock } from 'lucide-react'
import type { GameProposalMode } from '@/types'
const createSchema = z.object({
@@ -64,25 +64,38 @@ export function CreateMarathonPage() {
}
return (
<div className="max-w-lg mx-auto">
<div className="max-w-xl mx-auto">
{/* Back button */}
<Link to="/marathons" className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
<ArrowLeft className="w-4 h-4" />
<Link
to="/marathons"
className="inline-flex items-center gap-2 text-gray-400 hover:text-neon-400 mb-6 transition-colors group"
>
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
К списку марафонов
</Link>
<Card>
<CardHeader>
<CardTitle>Создать марафон</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{error && (
<div className="p-3 bg-red-500/20 border border-red-500 rounded-lg text-red-500 text-sm">
{error}
</div>
)}
<GlassCard variant="neon">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center border border-neon-500/30">
<Gamepad2 className="w-7 h-7 text-neon-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Создать марафон</h1>
<p className="text-gray-400 text-sm">Настройте свой игровой марафон</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{error && (
<div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
{/* Basic info */}
<div className="space-y-4">
<Input
label="Название"
placeholder="Введите название марафона"
@@ -91,132 +104,209 @@ export function CreateMarathonPage() {
/>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
<label className="block text-sm font-medium text-gray-300 mb-2">
Описание (необязательно)
</label>
<textarea
className="input min-h-[100px] resize-none"
placeholder="Введите описание"
placeholder="Расскажите о вашем марафоне..."
{...register('description')}
/>
</div>
</div>
<Input
label="Дата начала"
type="datetime-local"
error={errors.start_date?.message}
{...register('start_date')}
/>
<Input
label="Длительность (дней)"
type="number"
error={errors.duration_days?.message}
{...register('duration_days', { valueAsNumber: true })}
/>
{/* Тип марафона */}
{/* Date and duration */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Тип марафона
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
<Calendar className="w-4 h-4 text-neon-400" />
Дата начала
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setValue('is_public', false)}
className={`p-3 rounded-lg border-2 transition-all ${
!isPublic
? 'border-primary-500 bg-primary-500/10'
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
}`}
>
<Lock className={`w-5 h-5 mx-auto mb-1 ${!isPublic ? 'text-primary-400' : 'text-gray-400'}`} />
<div className={`text-sm font-medium ${!isPublic ? 'text-white' : 'text-gray-300'}`}>
Закрытый
</div>
<div className="text-xs text-gray-500 mt-1">
Вход по коду
</div>
</button>
<button
type="button"
onClick={() => setValue('is_public', true)}
className={`p-3 rounded-lg border-2 transition-all ${
isPublic
? 'border-primary-500 bg-primary-500/10'
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
}`}
>
<Globe className={`w-5 h-5 mx-auto mb-1 ${isPublic ? 'text-primary-400' : 'text-gray-400'}`} />
<div className={`text-sm font-medium ${isPublic ? 'text-white' : 'text-gray-300'}`}>
Открытый
</div>
<div className="text-xs text-gray-500 mt-1">
Виден всем
</div>
</button>
</div>
<input
type="datetime-local"
className="input w-full"
{...register('start_date')}
/>
{errors.start_date && (
<p className="text-red-400 text-xs mt-1">{errors.start_date.message}</p>
)}
</div>
{/* Кто может предлагать игры */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Кто может предлагать игры
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
<Clock className="w-4 h-4 text-accent-400" />
Длительность (дней)
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setValue('game_proposal_mode', 'all_participants')}
className={`p-3 rounded-lg border-2 transition-all ${
gameProposalMode === 'all_participants'
? 'border-primary-500 bg-primary-500/10'
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
}`}
>
<Users className={`w-5 h-5 mx-auto mb-1 ${gameProposalMode === 'all_participants' ? 'text-primary-400' : 'text-gray-400'}`} />
<div className={`text-sm font-medium ${gameProposalMode === 'all_participants' ? 'text-white' : 'text-gray-300'}`}>
Все участники
</div>
<div className="text-xs text-gray-500 mt-1">
С модерацией
</div>
</button>
<button
type="button"
onClick={() => setValue('game_proposal_mode', 'organizer_only')}
className={`p-3 rounded-lg border-2 transition-all ${
gameProposalMode === 'organizer_only'
? 'border-primary-500 bg-primary-500/10'
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
}`}
>
<UserCog className={`w-5 h-5 mx-auto mb-1 ${gameProposalMode === 'organizer_only' ? 'text-primary-400' : 'text-gray-400'}`} />
<div className={`text-sm font-medium ${gameProposalMode === 'organizer_only' ? 'text-white' : 'text-gray-300'}`}>
Только организатор
</div>
<div className="text-xs text-gray-500 mt-1">
Без модерации
</div>
</button>
</div>
<input
type="number"
className="input w-full"
min={1}
max={365}
{...register('duration_days', { valueAsNumber: true })}
/>
{errors.duration_days && (
<p className="text-red-400 text-xs mt-1">{errors.duration_days.message}</p>
)}
</div>
</div>
<div className="flex gap-3 pt-4">
<Button
{/* Marathon type */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Тип марафона
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
variant="secondary"
className="flex-1"
onClick={() => navigate('/marathons')}
onClick={() => setValue('is_public', false)}
className={`
relative p-4 rounded-xl border-2 transition-all duration-300 text-left group
${!isPublic
? 'border-neon-500/50 bg-neon-500/10 shadow-[0_0_20px_rgba(0,240,255,0.1)]'
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
>
Отмена
</Button>
<Button type="submit" className="flex-1" isLoading={isLoading}>
Создать
</Button>
<div className={`
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
${!isPublic ? 'bg-neon-500/20' : 'bg-dark-600'}
`}>
<Lock className={`w-5 h-5 ${!isPublic ? 'text-neon-400' : 'text-gray-400'}`} />
</div>
<div className={`font-semibold mb-1 ${!isPublic ? 'text-white' : 'text-gray-300'}`}>
Закрытый
</div>
<div className="text-xs text-gray-500">
Вход только по коду приглашения
</div>
{!isPublic && (
<div className="absolute top-3 right-3">
<Sparkles className="w-4 h-4 text-neon-400" />
</div>
)}
</button>
<button
type="button"
onClick={() => setValue('is_public', true)}
className={`
relative p-4 rounded-xl border-2 transition-all duration-300 text-left group
${isPublic
? 'border-accent-500/50 bg-accent-500/10 shadow-[0_0_20px_rgba(168,85,247,0.1)]'
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
>
<div className={`
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
${isPublic ? 'bg-accent-500/20' : 'bg-dark-600'}
`}>
<Globe className={`w-5 h-5 ${isPublic ? 'text-accent-400' : 'text-gray-400'}`} />
</div>
<div className={`font-semibold mb-1 ${isPublic ? 'text-white' : 'text-gray-300'}`}>
Открытый
</div>
<div className="text-xs text-gray-500">
Виден всем пользователям
</div>
{isPublic && (
<div className="absolute top-3 right-3">
<Sparkles className="w-4 h-4 text-accent-400" />
</div>
)}
</button>
</div>
</form>
</CardContent>
</Card>
</div>
{/* Game proposal mode */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Кто может предлагать игры
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setValue('game_proposal_mode', 'all_participants')}
className={`
relative p-4 rounded-xl border-2 transition-all duration-300 text-left
${gameProposalMode === 'all_participants'
? 'border-neon-500/50 bg-neon-500/10 shadow-[0_0_20px_rgba(0,240,255,0.1)]'
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
>
<div className={`
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
${gameProposalMode === 'all_participants' ? 'bg-neon-500/20' : 'bg-dark-600'}
`}>
<Users className={`w-5 h-5 ${gameProposalMode === 'all_participants' ? 'text-neon-400' : 'text-gray-400'}`} />
</div>
<div className={`font-semibold mb-1 ${gameProposalMode === 'all_participants' ? 'text-white' : 'text-gray-300'}`}>
Все участники
</div>
<div className="text-xs text-gray-500">
С модерацией организатором
</div>
{gameProposalMode === 'all_participants' && (
<div className="absolute top-3 right-3">
<Sparkles className="w-4 h-4 text-neon-400" />
</div>
)}
</button>
<button
type="button"
onClick={() => setValue('game_proposal_mode', 'organizer_only')}
className={`
relative p-4 rounded-xl border-2 transition-all duration-300 text-left
${gameProposalMode === 'organizer_only'
? 'border-accent-500/50 bg-accent-500/10 shadow-[0_0_20px_rgba(168,85,247,0.1)]'
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
>
<div className={`
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
${gameProposalMode === 'organizer_only' ? 'bg-accent-500/20' : 'bg-dark-600'}
`}>
<UserCog className={`w-5 h-5 ${gameProposalMode === 'organizer_only' ? 'text-accent-400' : 'text-gray-400'}`} />
</div>
<div className={`font-semibold mb-1 ${gameProposalMode === 'organizer_only' ? 'text-white' : 'text-gray-300'}`}>
Только организатор
</div>
<div className="text-xs text-gray-500">
Без модерации
</div>
{gameProposalMode === 'organizer_only' && (
<div className="absolute top-3 right-3">
<Sparkles className="w-4 h-4 text-accent-400" />
</div>
)}
</button>
</div>
</div>
{/* Actions */}
<div className="flex gap-3 pt-4 border-t border-dark-600">
<NeonButton
type="button"
variant="outline"
className="flex-1"
onClick={() => navigate('/marathons')}
>
Отмена
</NeonButton>
<NeonButton
type="submit"
className="flex-1"
isLoading={isLoading}
icon={<Sparkles className="w-4 h-4" />}
>
Создать
</NeonButton>
</div>
</form>
</GlassCard>
</div>
)
}

View File

@@ -1,113 +1,269 @@
import { Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { Button } from '@/components/ui'
import { Gamepad2, Users, Trophy, Sparkles } from 'lucide-react'
import { NeonButton, GradientButton, FeatureCard } from '@/components/ui'
import { Gamepad2, Users, Trophy, Sparkles, Zap, Target, Crown, ArrowRight } from 'lucide-react'
export function HomePage() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
return (
<div className="max-w-4xl mx-auto text-center">
{/* Hero */}
<div className="py-12">
<div className="flex justify-center mb-6">
<Gamepad2 className="w-20 h-20 text-primary-500" />
</div>
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
Игровой Марафон
</h1>
<p className="text-xl text-gray-400 mb-8 max-w-2xl mx-auto">
Соревнуйтесь с друзьями в игровых челленджах. Крутите колесо, выполняйте задания, зарабатывайте очки и станьте чемпионом!
</p>
<div className="-mt-8">
{/* Hero Section */}
<section className="relative min-h-[80vh] flex items-center justify-center overflow-hidden">
{/* Animated background */}
<div className="absolute inset-0 overflow-hidden">
{/* Gradient orbs */}
<div className="absolute top-1/4 -left-20 w-96 h-96 bg-neon-500/20 rounded-full blur-[100px] animate-float" />
<div className="absolute bottom-1/4 -right-20 w-96 h-96 bg-accent-500/20 rounded-full blur-[100px] animate-float" style={{ animationDelay: '-3s' }} />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-pink-500/10 rounded-full blur-[120px]" />
<div className="flex gap-4 justify-center">
{isAuthenticated ? (
<Link to="/marathons">
<Button size="lg">К марафонам</Button>
</Link>
) : (
<>
<Link to="/register">
<Button size="lg">Начать</Button>
{/* Grid lines */}
<div className="absolute inset-0 bg-[linear-gradient(rgba(0,240,255,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(0,240,255,0.03)_1px,transparent_1px)] bg-[size:100px_100px] [mask-image:radial-gradient(ellipse_50%_50%_at_50%_50%,black_40%,transparent_100%)]" />
</div>
{/* Content */}
<div className="relative z-10 max-w-5xl mx-auto text-center px-4">
{/* Logo */}
<div className="flex justify-center mb-8">
<div className="relative">
<Gamepad2 className="w-24 h-24 text-neon-500 animate-float drop-shadow-[0_0_30px_rgba(0,240,255,0.5)]" />
<div className="absolute inset-0 bg-neon-500/20 blur-2xl rounded-full" />
</div>
</div>
{/* Title with glitch effect */}
<h1 className="relative mb-6">
<span className="block text-5xl md:text-7xl font-bold font-display tracking-wider text-white">
ИГРОВОЙ
</span>
<span
className="glitch block text-5xl md:text-7xl font-bold font-display tracking-wider text-neon-500 neon-text"
data-text="МАРАФОН"
>
МАРАФОН
</span>
</h1>
{/* Subtitle with typing effect */}
<p className="text-xl md:text-2xl text-gray-300 mb-10 max-w-2xl mx-auto leading-relaxed">
Соревнуйтесь с друзьями в{' '}
<span className="text-neon-400">игровых челленджах</span>.
<br className="hidden md:block" />
Крутите колесо, выполняйте задания, станьте{' '}
<span className="text-accent-400">чемпионом</span>!
</p>
{/* CTA Buttons */}
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
{isAuthenticated ? (
<Link to="/marathons">
<GradientButton size="lg" icon={<ArrowRight className="w-5 h-5" />}>
К марафонам
</GradientButton>
</Link>
<Link to="/login">
<Button size="lg" variant="secondary">Войти</Button>
</Link>
</>
)}
</div>
</div>
{/* Features */}
<div className="grid md:grid-cols-3 gap-8 py-12">
<div className="card text-center">
<div className="flex justify-center mb-4">
<Sparkles className="w-12 h-12 text-yellow-500" />
) : (
<>
<Link to="/register">
<GradientButton size="lg" icon={<Zap className="w-5 h-5" />}>
Начать играть
</GradientButton>
</Link>
<Link to="/login">
<NeonButton size="lg" variant="outline" color="neon">
Войти
</NeonButton>
</Link>
</>
)}
</div>
{/* Stats */}
<div className="flex flex-wrap justify-center gap-8 mt-16">
<div className="text-center">
<div className="text-3xl font-bold text-neon-400">100+</div>
<div className="text-sm text-gray-500">Марафонов</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-accent-400">500+</div>
<div className="text-sm text-gray-500">Игроков</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-pink-400">2000+</div>
<div className="text-sm text-gray-500">Челленджей</div>
</div>
</div>
<h3 className="text-xl font-bold text-white mb-2">Случайные челленджи</h3>
<p className="text-gray-400">
Крутите колесо, чтобы получить случайную игру и задание. Проверьте свои навыки неожиданным способом!
</p>
</div>
<div className="card text-center">
<div className="flex justify-center mb-4">
<Users className="w-12 h-12 text-green-500" />
{/* Scroll indicator */}
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce">
<div className="w-6 h-10 border-2 border-gray-600 rounded-full flex justify-center pt-2">
<div className="w-1 h-2 bg-neon-500 rounded-full animate-pulse" />
</div>
<h3 className="text-xl font-bold text-white mb-2">Играйте с друзьями</h3>
<p className="text-gray-400">
Создавайте приватные марафоны и приглашайте друзей. Каждый добавляет свои любимые игры.
</p>
</div>
</section>
<div className="card text-center">
<div className="flex justify-center mb-4">
<Trophy className="w-12 h-12 text-primary-500" />
{/* Features Section */}
<section className="py-24 relative">
<div className="max-w-6xl mx-auto px-4">
<div className="text-center mb-16">
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
Почему <span className="gradient-neon-text">Игровой Марафон</span>?
</h2>
<p className="text-gray-400 max-w-2xl mx-auto">
Уникальный способ играть с друзьями. Случайные челленджи, честная конкуренция, незабываемые моменты.
</p>
</div>
<div className="grid md:grid-cols-3 gap-6 stagger-children">
<FeatureCard
icon={<Sparkles className="w-7 h-7" />}
title="Случайные челленджи"
description="Крутите колесо и получайте уникальные задания. ИИ генерирует челленджи специально под ваши игры."
color="neon"
/>
<FeatureCard
icon={<Users className="w-7 h-7" />}
title="Играйте с друзьями"
description="Создавайте приватные марафоны. Каждый добавляет свои игры, все соревнуются на равных."
color="purple"
/>
<FeatureCard
icon={<Trophy className="w-7 h-7" />}
title="Зарабатывайте очки"
description="Выполняйте задания, собирайте серии побед. Бонусные множители за стрики!"
color="pink"
/>
</div>
<h3 className="text-xl font-bold text-white mb-2">Соревнуйтесь за очки</h3>
<p className="text-gray-400">
Выполняйте задания, чтобы зарабатывать очки. Собирайте серии для бонусных множителей!
</p>
</div>
</div>
</section>
{/* How it works */}
<div className="py-12">
<h2 className="text-2xl font-bold text-white mb-8">Как это работает</h2>
<div className="grid md:grid-cols-4 gap-6 text-left">
<div className="relative">
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">1</div>
<div className="relative z-10 pt-6">
<h4 className="font-bold text-white mb-2">Создайте марафон</h4>
<p className="text-gray-400 text-sm">Начните новый марафон и пригласите друзей по уникальному коду</p>
</div>
<section className="py-24 relative">
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-dark-800/50 to-transparent" />
<div className="max-w-6xl mx-auto px-4 relative z-10">
<div className="text-center mb-16">
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
Как это работает
</h2>
<p className="text-gray-400">
Четыре простых шага до победы
</p>
</div>
{/* Timeline */}
<div className="relative">
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">2</div>
<div className="relative z-10 pt-6">
<h4 className="font-bold text-white mb-2">Добавьте игры</h4>
<p className="text-gray-400 text-sm">Все добавляют игры, в которые хотят играть. ИИ генерирует задания</p>
</div>
</div>
{/* Connection line */}
<div className="hidden md:block absolute top-12 left-0 right-0 h-0.5 bg-gradient-to-r from-neon-500 via-accent-500 to-pink-500" />
<div className="relative">
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">3</div>
<div className="relative z-10 pt-6">
<h4 className="font-bold text-white mb-2">Крутите и играйте</h4>
<p className="text-gray-400 text-sm">Крутите колесо, получите задание, выполните его и отправьте доказательство</p>
</div>
</div>
<div className="grid md:grid-cols-4 gap-8">
{[
{
step: 1,
icon: <Gamepad2 className="w-6 h-6" />,
title: 'Создайте марафон',
desc: 'Начните новый марафон и пригласите друзей по коду',
color: 'neon',
},
{
step: 2,
icon: <Target className="w-6 h-6" />,
title: 'Добавьте игры',
desc: 'Каждый добавляет игры. ИИ генерирует задания',
color: 'neon',
},
{
step: 3,
icon: <Zap className="w-6 h-6" />,
title: 'Крутите и играйте',
desc: 'Крутите колесо, выполняйте задания',
color: 'accent',
},
{
step: 4,
icon: <Crown className="w-6 h-6" />,
title: 'Победите!',
desc: 'Зарабатывайте очки и станьте чемпионом',
color: 'pink',
},
].map((item, index) => (
<div key={item.step} className="relative text-center group">
{/* Step circle */}
<div
className={`
relative z-10 w-24 h-24 mx-auto mb-6 rounded-2xl
bg-dark-800 border-2 transition-all duration-300
flex items-center justify-center
group-hover:-translate-y-2
${item.color === 'neon' ? 'border-neon-500/50 group-hover:border-neon-500 group-hover:shadow-[0_0_30px_rgba(0,240,255,0.3)]' : ''}
${item.color === 'accent' ? 'border-accent-500/50 group-hover:border-accent-500 group-hover:shadow-[0_0_30px_rgba(168,85,247,0.3)]' : ''}
${item.color === 'pink' ? 'border-pink-500/50 group-hover:border-pink-500 group-hover:shadow-[0_0_30px_rgba(236,72,153,0.3)]' : ''}
`}
style={{ animationDelay: `${index * 100}ms` }}
>
<div className={`
${item.color === 'neon' ? 'text-neon-500' : ''}
${item.color === 'accent' ? 'text-accent-500' : ''}
${item.color === 'pink' ? 'text-pink-500' : ''}
`}>
{item.icon}
</div>
<div className={`
absolute -top-2 -right-2 w-8 h-8 rounded-full
flex items-center justify-center text-sm font-bold
${item.color === 'neon' ? 'bg-neon-500 text-dark-900' : ''}
${item.color === 'accent' ? 'bg-accent-500 text-white' : ''}
${item.color === 'pink' ? 'bg-pink-500 text-white' : ''}
`}>
{item.step}
</div>
</div>
<div className="relative">
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">4</div>
<div className="relative z-10 pt-6">
<h4 className="font-bold text-white mb-2">Победите!</h4>
<p className="text-gray-400 text-sm">Зарабатывайте очки, поднимайтесь в таблице лидеров, станьте чемпионом!</p>
<h4 className="text-lg font-semibold text-white mb-2">
{item.title}
</h4>
<p className="text-gray-400 text-sm">
{item.desc}
</p>
</div>
))}
</div>
</div>
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-24 relative">
<div className="max-w-4xl mx-auto px-4 text-center">
<div className="glass-neon rounded-2xl p-12 relative overflow-hidden">
{/* Background glow */}
<div className="absolute inset-0 bg-gradient-to-r from-neon-500/5 via-accent-500/5 to-pink-500/5" />
<div className="relative z-10">
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
Готовы к соревнованиям?
</h2>
<p className="text-gray-300 mb-8 max-w-xl mx-auto">
Присоединяйтесь к сотням игроков, которые уже соревнуются в игровых челленджах
</p>
{isAuthenticated ? (
<Link to="/marathons">
<GradientButton size="lg" icon={<ArrowRight className="w-5 h-5" />}>
Перейти к марафонам
</GradientButton>
</Link>
) : (
<Link to="/register">
<GradientButton size="lg" icon={<Zap className="w-5 h-5" />}>
Создать аккаунт бесплатно
</GradientButton>
</Link>
)}
</div>
</div>
</div>
</section>
</div>
)
}

View File

@@ -3,8 +3,8 @@ import { useParams, useNavigate, Link } from 'react-router-dom'
import { marathonsApi } from '@/api'
import type { MarathonPublicInfo } from '@/types'
import { useAuthStore } from '@/store/auth'
import { Button, Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
import { Users, Loader2, Trophy, UserPlus, LogIn } from 'lucide-react'
import { NeonButton, GlassCard } from '@/components/ui'
import { Users, Loader2, Trophy, UserPlus, LogIn, Gamepad2, AlertCircle, Sparkles, Crown } from 'lucide-react'
export function InvitePage() {
const { code } = useParams<{ code: string }>()
@@ -63,8 +63,9 @@ export function InvitePage() {
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
<div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка приглашения...</p>
</div>
)
}
@@ -72,97 +73,154 @@ export function InvitePage() {
if (error || !marathon) {
return (
<div className="max-w-md mx-auto">
<Card>
<CardContent className="text-center py-8">
<div className="text-red-400 mb-4">{error || 'Марафон не найден'}</div>
<Link to="/marathons">
<Button variant="secondary">К списку марафонов</Button>
</Link>
</CardContent>
</Card>
<GlassCard className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-red-500/10 border border-red-500/30 flex items-center justify-center">
<AlertCircle className="w-8 h-8 text-red-400" />
</div>
<h2 className="text-xl font-bold text-white mb-2">Ошибка</h2>
<p className="text-gray-400 mb-6">{error || 'Марафон не найден'}</p>
<Link to="/marathons">
<NeonButton variant="outline">К списку марафонов</NeonButton>
</Link>
</GlassCard>
</div>
)
}
const statusText = {
preparing: 'Подготовка',
active: 'Активен',
finished: 'Завершён',
}[marathon.status]
const getStatusConfig = (status: string) => {
switch (status) {
case 'preparing':
return {
color: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
text: 'Подготовка',
dot: 'bg-yellow-500',
}
case 'active':
return {
color: 'bg-neon-500/20 text-neon-400 border-neon-500/30',
text: 'Активен',
dot: 'bg-neon-500 animate-pulse',
}
case 'finished':
return {
color: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
text: 'Завершён',
dot: 'bg-gray-500',
}
default:
return {
color: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
text: status,
dot: 'bg-gray-500',
}
}
}
const status = getStatusConfig(marathon.status)
return (
<div className="max-w-md mx-auto">
<Card>
<CardHeader className="text-center">
<CardTitle className="flex items-center justify-center gap-2">
<Trophy className="w-6 h-6 text-primary-500" />
Приглашение в марафон
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Marathon info */}
<div className="text-center">
<h2 className="text-2xl font-bold text-white mb-2">{marathon.title}</h2>
{marathon.description && (
<p className="text-gray-400 text-sm mb-4">{marathon.description}</p>
)}
<div className="flex items-center justify-center gap-4 text-sm text-gray-400">
<span className="flex items-center gap-1">
<Users className="w-4 h-4" />
{marathon.participants_count} участников
</span>
<span className={`px-2 py-0.5 rounded text-xs ${
marathon.status === 'active' ? 'bg-green-900/50 text-green-400' :
marathon.status === 'preparing' ? 'bg-yellow-900/50 text-yellow-400' :
'bg-gray-700 text-gray-400'
}`}>
{statusText}
</span>
<div className="min-h-[70vh] flex items-center justify-center px-4">
{/* Background effects */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 -left-32 w-96 h-96 bg-neon-500/10 rounded-full blur-[100px]" />
<div className="absolute bottom-1/4 -right-32 w-96 h-96 bg-accent-500/10 rounded-full blur-[100px]" />
</div>
<div className="relative w-full max-w-md">
<GlassCard variant="neon" className="animate-scale-in">
{/* Header */}
<div className="text-center mb-8">
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 border border-neon-500/30 flex items-center justify-center">
<Trophy className="w-10 h-10 text-neon-400" />
</div>
<p className="text-gray-500 text-xs mt-2">
Организатор: {marathon.creator_nickname}
</p>
<h1 className="text-xl font-bold text-white mb-1">Приглашение в марафон</h1>
<p className="text-gray-400 text-sm">Вас пригласили присоединиться</p>
</div>
{/* Marathon info */}
<div className="glass rounded-xl p-5 mb-6">
<div className="flex items-start gap-4">
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center border border-neon-500/20 flex-shrink-0">
<Gamepad2 className="w-7 h-7 text-neon-400" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-xl font-bold text-white mb-1 truncate">{marathon.title}</h2>
{marathon.description && (
<p className="text-gray-400 text-sm line-clamp-2 mb-3">{marathon.description}</p>
)}
<div className="flex flex-wrap items-center gap-3">
<span className={`px-2.5 py-1 rounded-full text-xs font-medium border flex items-center gap-1.5 ${status.color}`}>
<span className={`w-1.5 h-1.5 rounded-full ${status.dot}`} />
{status.text}
</span>
<span className="flex items-center gap-1.5 text-sm text-gray-400">
<Users className="w-4 h-4" />
{marathon.participants_count}
</span>
</div>
</div>
</div>
{/* Organizer */}
<div className="mt-4 pt-4 border-t border-dark-600 flex items-center gap-2 text-sm text-gray-500">
<Crown className="w-4 h-4 text-yellow-500" />
<span>Организатор:</span>
<span className="text-gray-300">{marathon.creator_nickname}</span>
</div>
</div>
{/* Actions */}
{marathon.status === 'finished' ? (
<div className="text-center text-gray-400">
Этот марафон уже завершён
<div className="text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-gray-500/10 flex items-center justify-center">
<AlertCircle className="w-6 h-6 text-gray-400" />
</div>
<p className="text-gray-400 mb-4">Этот марафон уже завершён</p>
<Link to="/marathons">
<NeonButton variant="outline" className="w-full">
К списку марафонов
</NeonButton>
</Link>
</div>
) : isAuthenticated ? (
/* Authenticated - show join button */
<Button
<NeonButton
className="w-full"
size="lg"
onClick={handleJoin}
isLoading={isJoining}
icon={<Sparkles className="w-5 h-5" />}
>
<UserPlus className="w-4 h-4 mr-2" />
Присоединиться к марафону
</Button>
Присоединиться
</NeonButton>
) : (
/* Not authenticated - show login/register options */
<div className="space-y-3">
<div className="space-y-4">
<p className="text-center text-gray-400 text-sm">
Чтобы присоединиться, войдите или зарегистрируйтесь
</p>
<Button
<NeonButton
className="w-full"
size="lg"
onClick={() => handleAuthRedirect('/login')}
icon={<LogIn className="w-5 h-5" />}
>
<LogIn className="w-4 h-4 mr-2" />
Войти
</Button>
<Button
variant="secondary"
</NeonButton>
<NeonButton
variant="outline"
className="w-full"
onClick={() => handleAuthRedirect('/register')}
icon={<UserPlus className="w-5 h-5" />}
>
<UserPlus className="w-4 h-4 mr-2" />
Зарегистрироваться
</Button>
</NeonButton>
</div>
)}
</CardContent>
</Card>
</GlassCard>
{/* Decorative elements */}
<div className="absolute -top-4 -left-4 w-24 h-24 border border-neon-500/20 rounded-2xl -z-10" />
<div className="absolute -bottom-4 -right-4 w-32 h-32 border border-accent-500/20 rounded-2xl -z-10" />
</div>
</div>
)
}

View File

@@ -2,9 +2,9 @@ import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import { marathonsApi } from '@/api'
import type { LeaderboardEntry } from '@/types'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
import { GlassCard } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import { Trophy, Flame, ArrowLeft, Loader2 } from 'lucide-react'
import { Trophy, Flame, ArrowLeft, Loader2, Crown, Medal, Award, Star, Zap, Target } from 'lucide-react'
export function LeaderboardPage() {
const { id } = useParams<{ id: string }>()
@@ -28,92 +28,254 @@ export function LeaderboardPage() {
}
}
const getRankIcon = (rank: number) => {
const getRankConfig = (rank: number) => {
switch (rank) {
case 1:
return <Trophy className="w-6 h-6 text-yellow-500" />
return {
icon: <Crown className="w-6 h-6" />,
color: 'text-yellow-400',
bg: 'bg-yellow-500/20',
border: 'border-yellow-500/30',
glow: 'shadow-[0_0_20px_rgba(234,179,8,0.3)]',
gradient: 'from-yellow-500/20 via-transparent to-transparent',
}
case 2:
return <Trophy className="w-6 h-6 text-gray-400" />
return {
icon: <Medal className="w-6 h-6" />,
color: 'text-gray-300',
bg: 'bg-gray-400/20',
border: 'border-gray-400/30',
glow: 'shadow-[0_0_15px_rgba(156,163,175,0.2)]',
gradient: 'from-gray-400/10 via-transparent to-transparent',
}
case 3:
return <Trophy className="w-6 h-6 text-amber-700" />
return {
icon: <Award className="w-6 h-6" />,
color: 'text-amber-600',
bg: 'bg-amber-600/20',
border: 'border-amber-600/30',
glow: 'shadow-[0_0_15px_rgba(217,119,6,0.2)]',
gradient: 'from-amber-600/10 via-transparent to-transparent',
}
default:
return <span className="text-gray-500 font-mono w-6 text-center">{rank}</span>
return {
icon: <span className="text-gray-500 font-mono font-bold">{rank}</span>,
color: 'text-gray-500',
bg: 'bg-dark-700',
border: 'border-dark-600',
glow: '',
gradient: '',
}
}
}
// Top 3 for podium
const topThree = leaderboard.slice(0, 3)
// Calculate stats
const totalPoints = leaderboard.reduce((acc, e) => acc + e.total_points, 0)
const maxStreak = Math.max(...leaderboard.map(e => e.current_streak), 0)
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
<div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка рейтинга...</p>
</div>
)
}
return (
<div className="max-w-2xl mx-auto">
<div className="max-w-3xl mx-auto">
{/* Header */}
<div className="flex items-center gap-4 mb-8">
<Link to={`/marathons/${id}`} className="text-gray-400 hover:text-white">
<ArrowLeft className="w-6 h-6" />
<Link
to={`/marathons/${id}`}
className="w-10 h-10 rounded-xl bg-dark-700 border border-dark-600 flex items-center justify-center text-gray-400 hover:text-white hover:border-neon-500/30 transition-all"
>
<ArrowLeft className="w-5 h-5" />
</Link>
<h1 className="text-2xl font-bold text-white">Таблица лидеров</h1>
<div>
<h1 className="text-2xl font-bold text-white">Таблица лидеров</h1>
<p className="text-gray-400 text-sm">Рейтинг участников марафона</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="w-5 h-5 text-yellow-500" />
Рейтинг
</CardTitle>
</CardHeader>
<CardContent>
{leaderboard.length === 0 ? (
<p className="text-center text-gray-400 py-8">Пока нет участников</p>
) : (
<div className="space-y-2">
{leaderboard.map((entry) => (
<div
key={entry.user.id}
className={`flex items-center gap-4 p-4 rounded-lg ${
entry.user.id === user?.id
? 'bg-primary-500/20 border border-primary-500/50'
: 'bg-gray-900'
}`}
>
<div className="flex items-center justify-center w-8">
{getRankIcon(entry.rank)}
{leaderboard.length === 0 ? (
<GlassCard className="text-center py-16">
<div className="w-20 h-20 mx-auto mb-6 rounded-2xl bg-dark-700 flex items-center justify-center">
<Trophy className="w-10 h-10 text-gray-600" />
</div>
<h3 className="text-xl font-semibold text-white mb-2">Пока нет участников</h3>
<p className="text-gray-400">Станьте первым в рейтинге!</p>
</GlassCard>
) : (
<>
{/* Podium for top 3 */}
{topThree.length >= 3 && (
<div className="mb-8">
<div className="flex items-end justify-center gap-4 mb-4">
{/* 2nd place */}
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '100ms' }}>
<div className={`
w-20 h-20 rounded-2xl mb-3 flex items-center justify-center
bg-gray-400/10 border border-gray-400/30
shadow-[0_0_20px_rgba(156,163,175,0.2)]
`}>
<span className="text-3xl font-bold text-gray-300">2</span>
</div>
<div className="flex-1">
<div className="font-medium text-white">
{entry.user.nickname}
{entry.user.id === user?.id && (
<span className="ml-2 text-xs text-primary-400">(Вы)</span>
)}
</div>
<div className="text-sm text-gray-400">
{entry.completed_count} выполнено, {entry.dropped_count} пропущено
</div>
</div>
{entry.current_streak > 0 && (
<div className="flex items-center gap-1 text-yellow-500">
<Flame className="w-4 h-4" />
<span className="text-sm">{entry.current_streak}</span>
</div>
)}
<div className="text-right">
<div className="text-xl font-bold text-primary-400">
{entry.total_points}
</div>
<div className="text-xs text-gray-500">очков</div>
<div className="glass rounded-xl p-4 text-center w-28">
<Medal className="w-6 h-6 text-gray-300 mx-auto mb-2" />
<p className="text-sm font-medium text-white truncate">{topThree[1].user.nickname}</p>
<p className="text-xs text-gray-400">{topThree[1].total_points} очков</p>
</div>
</div>
))}
{/* 1st place */}
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '0ms' }}>
<div className={`
w-24 h-24 rounded-2xl mb-3 flex items-center justify-center
bg-yellow-500/20 border border-yellow-500/30
shadow-[0_0_30px_rgba(234,179,8,0.4)]
`}>
<Crown className="w-10 h-10 text-yellow-400" />
</div>
<div className="glass-neon rounded-xl p-4 text-center w-32">
<Star className="w-6 h-6 text-yellow-400 mx-auto mb-2" />
<p className="font-semibold text-white truncate">{topThree[0].user.nickname}</p>
<p className="text-sm text-neon-400 font-bold">{topThree[0].total_points} очков</p>
</div>
</div>
{/* 3rd place */}
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '200ms' }}>
<div className={`
w-20 h-20 rounded-2xl mb-3 flex items-center justify-center
bg-amber-600/10 border border-amber-600/30
shadow-[0_0_20px_rgba(217,119,6,0.2)]
`}>
<span className="text-3xl font-bold text-amber-600">3</span>
</div>
<div className="glass rounded-xl p-4 text-center w-28">
<Award className="w-6 h-6 text-amber-600 mx-auto mb-2" />
<p className="text-sm font-medium text-white truncate">{topThree[2].user.nickname}</p>
<p className="text-xs text-gray-400">{topThree[2].total_points} очков</p>
</div>
</div>
</div>
</div>
)}
</CardContent>
</Card>
{/* Stats row */}
<div className="grid grid-cols-3 gap-4 mb-8">
<div className="glass rounded-xl p-4 text-center">
<Trophy className="w-6 h-6 text-neon-400 mx-auto mb-2" />
<p className="text-2xl font-bold text-white">{leaderboard.length}</p>
<p className="text-xs text-gray-400">Участников</p>
</div>
<div className="glass rounded-xl p-4 text-center">
<Zap className="w-6 h-6 text-accent-400 mx-auto mb-2" />
<p className="text-2xl font-bold text-white">{totalPoints}</p>
<p className="text-xs text-gray-400">Всего очков</p>
</div>
<div className="glass rounded-xl p-4 text-center">
<Flame className="w-6 h-6 text-orange-400 mx-auto mb-2" />
<p className="text-2xl font-bold text-white">{maxStreak}</p>
<p className="text-xs text-gray-400">Макс. серия</p>
</div>
</div>
{/* Full leaderboard */}
<GlassCard>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-neon-500/20 flex items-center justify-center">
<Target className="w-5 h-5 text-neon-400" />
</div>
<div>
<h3 className="font-semibold text-white">Полный рейтинг</h3>
<p className="text-sm text-gray-400">Все участники марафона</p>
</div>
</div>
<div className="space-y-2">
{leaderboard.map((entry, index) => {
const isCurrentUser = entry.user.id === user?.id
const rankConfig = getRankConfig(entry.rank)
return (
<div
key={entry.user.id}
className={`
relative flex items-center gap-4 p-4 rounded-xl
transition-all duration-300 group
${isCurrentUser
? 'bg-neon-500/10 border border-neon-500/30 shadow-[0_0_20px_rgba(0,240,255,0.1)]'
: `${rankConfig.bg} border ${rankConfig.border} hover:border-neon-500/20`
}
`}
style={{ animationDelay: `${index * 50}ms` }}
>
{/* Gradient overlay for top 3 */}
{entry.rank <= 3 && (
<div className={`absolute inset-0 bg-gradient-to-r ${rankConfig.gradient} rounded-xl pointer-events-none`} />
)}
{/* Rank */}
<div className={`
relative w-10 h-10 rounded-xl flex items-center justify-center
${rankConfig.bg} ${rankConfig.color} ${rankConfig.glow}
`}>
{rankConfig.icon}
</div>
{/* User info */}
<div className="relative flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`font-semibold truncate ${isCurrentUser ? 'text-neon-400' : 'text-white'}`}>
{entry.user.nickname}
</span>
{isCurrentUser && (
<span className="px-2 py-0.5 text-xs font-medium bg-neon-500/20 text-neon-400 rounded-full border border-neon-500/30">
Вы
</span>
)}
</div>
<div className="flex items-center gap-3 text-sm text-gray-400">
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-green-500 rounded-full" />
{entry.completed_count} выполнено
</span>
{entry.dropped_count > 0 && (
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-red-500 rounded-full" />
{entry.dropped_count} пропущено
</span>
)}
</div>
</div>
{/* Streak */}
{entry.current_streak > 0 && (
<div className="relative flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-orange-500/10 border border-orange-500/20">
<Flame className="w-4 h-4 text-orange-400" />
<span className="text-sm font-semibold text-orange-400">{entry.current_streak}</span>
</div>
)}
{/* Points */}
<div className="relative text-right">
<div className={`text-xl font-bold ${isCurrentUser ? 'text-neon-400' : 'text-white'}`}>
{entry.total_points}
</div>
<div className="text-xs text-gray-500">очков</div>
</div>
</div>
)
})}
</div>
</GlassCard>
</>
)}
</div>
)
}

View File

@@ -2,13 +2,13 @@ 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 { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
import { NeonButton, Input, GlassCard, StatsCard } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
import {
Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye,
ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft
ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft, Zap
} from 'lucide-react'
export function LobbyPage() {
@@ -72,17 +72,14 @@ export function LobbyPage() {
const marathonData = await marathonsApi.get(parseInt(id))
setMarathon(marathonData)
// Load games - organizers see all, participants see approved + own
const gamesData = await gamesApi.list(parseInt(id))
setGames(gamesData)
// If organizer, load pending games separately
if (marathonData.my_participation?.role === 'organizer' || user?.role === 'admin') {
try {
const pending = await gamesApi.listPending(parseInt(id))
setPendingGames(pending)
} catch {
// If not authorized, just ignore
setPendingGames([])
}
}
@@ -175,7 +172,6 @@ export function LobbyPage() {
setExpandedGameId(gameId)
// Load challenges if we haven't loaded them yet
if (!gameChallenges[gameId]) {
setLoadingChallenges(gameId)
try {
@@ -183,7 +179,6 @@ export function LobbyPage() {
setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
} catch (error) {
console.error('Failed to load challenges:', error)
// Set empty array to prevent repeated attempts
setGameChallenges(prev => ({ ...prev, [gameId]: [] }))
} finally {
setLoadingChallenges(null)
@@ -210,7 +205,6 @@ export function LobbyPage() {
proof_hint: newChallenge.proof_hint.trim() || undefined,
})
toast.success('Задание добавлено')
// Reset form
setNewChallenge({
title: '',
description: '',
@@ -222,7 +216,6 @@ export function LobbyPage() {
proof_hint: '',
})
setAddingChallengeToGameId(null)
// Refresh challenges
const challenges = await gamesApi.getChallenges(gameId)
setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
await loadData()
@@ -246,10 +239,9 @@ export function LobbyPage() {
try {
await gamesApi.deleteChallenge(challengeId)
// Refresh challenges for this game
const challenges = await gamesApi.getChallenges(gameId)
setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
await loadData() // Refresh game counts
await loadData()
} catch (error) {
console.error('Failed to delete challenge:', error)
}
@@ -283,7 +275,7 @@ export function LobbyPage() {
const result = await gamesApi.saveChallenges(parseInt(id), previewChallenges)
setGenerateMessage(result.message)
setPreviewChallenges(null)
setGameChallenges({}) // Clear cache to reload
setGameChallenges({})
await loadData()
} catch (error) {
console.error('Failed to save challenges:', error)
@@ -337,8 +329,9 @@ export function LobbyPage() {
if (isLoading || !marathon) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
<div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка лобби...</p>
</div>
)
}
@@ -351,21 +344,21 @@ export function LobbyPage() {
switch (status) {
case 'approved':
return (
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-green-900/50 text-green-400">
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-lg bg-green-500/20 text-green-400 border border-green-500/30">
<CheckCircle className="w-3 h-3" />
Одобрено
</span>
)
case 'pending':
return (
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-yellow-900/50 text-yellow-400">
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-lg bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">
<Clock className="w-3 h-3" />
На модерации
</span>
)
case 'rejected':
return (
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-red-900/50 text-red-400">
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-lg bg-red-500/20 text-red-400 border border-red-500/30">
<XCircle className="w-3 h-3" />
Отклонено
</span>
@@ -376,11 +369,11 @@ export function LobbyPage() {
}
const renderGameCard = (game: Game, showModeration = false) => (
<div key={game.id} className="bg-gray-900 rounded-lg overflow-hidden">
<div key={game.id} className="glass rounded-xl overflow-hidden border border-dark-600">
{/* Game header */}
<div
className={`flex items-center justify-between p-4 ${
(game.status === 'approved') ? 'cursor-pointer hover:bg-gray-800/50' : ''
(game.status === 'approved') ? 'cursor-pointer hover:bg-dark-700/50 transition-colors' : ''
}`}
onClick={() => game.status === 'approved' && handleToggleGameChallenges(game.id)}
>
@@ -394,14 +387,22 @@ export function LobbyPage() {
)}
</span>
)}
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-neon-500/20 to-accent-500/20 border border-neon-500/20 flex items-center justify-center flex-shrink-0">
<Gamepad2 className="w-5 h-5 text-neon-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="font-medium text-white">{game.title}</h4>
<h4 className="font-semibold text-white">{game.title}</h4>
{getStatusBadge(game.status)}
</div>
<div className="text-sm text-gray-400 flex items-center gap-3 flex-wrap">
{game.genre && <span>{game.genre}</span>}
{game.status === 'approved' && <span>{game.challenges_count} заданий</span>}
{game.status === 'approved' && (
<span className="flex items-center gap-1">
<Sparkles className="w-3 h-3 text-accent-400" />
{game.challenges_count} заданий
</span>
)}
{game.proposed_by && (
<span className="flex items-center gap-1 text-gray-500">
<User className="w-3 h-3" />
@@ -414,49 +415,43 @@ export function LobbyPage() {
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
{showModeration && game.status === 'pending' && (
<>
<Button
variant="ghost"
size="sm"
<button
onClick={() => handleApproveGame(game.id)}
disabled={moderatingGameId === game.id}
className="text-green-400 hover:text-green-300"
className="p-2 rounded-lg text-green-400 hover:bg-green-500/10 transition-colors disabled:opacity-50"
>
{moderatingGameId === game.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<CheckCircle className="w-4 h-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
</button>
<button
onClick={() => handleRejectGame(game.id)}
disabled={moderatingGameId === game.id}
className="text-red-400 hover:text-red-300"
className="p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-50"
>
<XCircle className="w-4 h-4" />
</Button>
</button>
</>
)}
{(isOrganizer || game.proposed_by?.id === user?.id) && (
<Button
variant="ghost"
size="sm"
<button
onClick={() => handleDeleteGame(game.id)}
className="text-red-400 hover:text-red-300"
className="p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors"
>
<Trash2 className="w-4 h-4" />
</Button>
</button>
)}
</div>
</div>
{/* Expanded challenges list */}
{expandedGameId === game.id && (
<div className="border-t border-gray-800 p-4 space-y-2">
<div className="border-t border-dark-600 p-4 space-y-2 bg-dark-800/30">
{loadingChallenges === game.id ? (
<div className="flex justify-center py-4">
<Loader2 className="w-5 h-5 animate-spin text-gray-400" />
<Loader2 className="w-5 h-5 animate-spin text-neon-400" />
</div>
) : (
<>
@@ -464,24 +459,24 @@ export function LobbyPage() {
gameChallenges[game.id].map((challenge) => (
<div
key={challenge.id}
className="flex items-start justify-between gap-3 p-3 bg-gray-800 rounded-lg"
className="flex items-start justify-between gap-3 p-3 bg-dark-700/50 rounded-lg border border-dark-600"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs px-2 py-0.5 rounded ${
challenge.difficulty === 'easy' ? 'bg-green-900/50 text-green-400' :
challenge.difficulty === 'medium' ? 'bg-yellow-900/50 text-yellow-400' :
'bg-red-900/50 text-red-400'
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
'bg-red-500/20 text-red-400 border-red-500/30'
}`}>
{challenge.difficulty === 'easy' ? 'Легко' :
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
</span>
<span className="text-xs text-primary-400 font-medium">
<span className="text-xs text-neon-400 font-semibold">
+{challenge.points}
</span>
{challenge.is_generated && (
<span className="text-xs text-gray-500">
<Sparkles className="w-3 h-3 inline" /> ИИ
<span className="text-xs text-gray-500 flex items-center gap-1">
<Sparkles className="w-3 h-3" /> ИИ
</span>
)}
</div>
@@ -489,19 +484,17 @@ export function LobbyPage() {
<p className="text-xs text-gray-400 mt-1">{challenge.description}</p>
</div>
{isOrganizer && (
<Button
variant="ghost"
size="sm"
<button
onClick={() => handleDeleteChallenge(challenge.id, game.id)}
className="text-red-400 hover:text-red-300 shrink-0"
className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors shrink-0"
>
<Trash2 className="w-3 h-3" />
</Button>
</button>
)}
</div>
))
) : (
<p className="text-center text-gray-500 py-2 text-sm">
<p className="text-center text-gray-500 py-4 text-sm">
Нет заданий
</p>
)}
@@ -509,8 +502,11 @@ export function LobbyPage() {
{/* Add challenge form */}
{isOrganizer && game.status === 'approved' && (
addingChallengeToGameId === game.id ? (
<div className="mt-4 p-4 bg-gray-800 rounded-lg space-y-3 border border-gray-700">
<h4 className="font-medium text-white text-sm">Новое задание</h4>
<div className="mt-4 p-4 glass rounded-xl space-y-3 border border-neon-500/20">
<h4 className="font-semibold text-white text-sm flex items-center gap-2">
<Plus className="w-4 h-4 text-neon-400" />
Новое задание
</h4>
<Input
placeholder="Название задания"
value={newChallenge.title}
@@ -520,7 +516,7 @@ export function LobbyPage() {
placeholder="Описание (что нужно сделать)"
value={newChallenge.description}
onChange={(e) => setNewChallenge(prev => ({ ...prev, description: e.target.value }))}
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm resize-none"
className="input w-full resize-none"
rows={2}
/>
<div className="grid grid-cols-2 gap-2">
@@ -529,7 +525,7 @@ export function LobbyPage() {
<select
value={newChallenge.type}
onChange={(e) => setNewChallenge(prev => ({ ...prev, type: e.target.value }))}
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm"
className="input w-full"
>
<option value="completion">Прохождение</option>
<option value="no_death">Без смертей</option>
@@ -544,7 +540,7 @@ export function LobbyPage() {
<select
value={newChallenge.difficulty}
onChange={(e) => setNewChallenge(prev => ({ ...prev, difficulty: e.target.value }))}
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm"
className="input w-full"
>
<option value="easy">Легко (20-40 очков)</option>
<option value="medium">Средне (45-75 очков)</option>
@@ -575,11 +571,11 @@ export function LobbyPage() {
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-gray-400 mb-1 block">Тип доказательства</label>
<label className="text-xs text-gray-400 mb-1 block">Тип пруфа</label>
<select
value={newChallenge.proof_type}
onChange={(e) => setNewChallenge(prev => ({ ...prev, proof_type: e.target.value }))}
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm"
className="input w-full"
>
<option value="screenshot">Скриншот</option>
<option value="video">Видео</option>
@@ -589,44 +585,42 @@ export function LobbyPage() {
<div>
<label className="text-xs text-gray-400 mb-1 block">Подсказка</label>
<Input
placeholder="Что должно быть на пруфе"
placeholder="Что на пруфе"
value={newChallenge.proof_hint}
onChange={(e) => setNewChallenge(prev => ({ ...prev, proof_hint: e.target.value }))}
/>
</div>
</div>
<div className="flex gap-2">
<Button
<NeonButton
size="sm"
onClick={() => handleCreateChallenge(game.id)}
isLoading={isCreatingChallenge}
disabled={!newChallenge.title || !newChallenge.description}
icon={<Plus className="w-4 h-4" />}
>
<Plus className="w-4 h-4 mr-1" />
Добавить
</Button>
<Button
variant="ghost"
</NeonButton>
<NeonButton
variant="outline"
size="sm"
onClick={() => setAddingChallengeToGameId(null)}
>
Отмена
</Button>
</NeonButton>
</div>
</div>
) : (
<Button
variant="ghost"
size="sm"
<button
onClick={() => {
setAddingChallengeToGameId(game.id)
setExpandedGameId(game.id)
}}
className="w-full mt-2 border border-dashed border-gray-700 text-gray-400 hover:text-white hover:border-gray-600"
className="w-full mt-2 p-3 rounded-lg border-2 border-dashed border-dark-600 text-gray-400 hover:text-neon-400 hover:border-neon-500/30 transition-all flex items-center justify-center gap-2"
>
<Plus className="w-4 h-4 mr-1" />
<Plus className="w-4 h-4" />
Добавить задание вручную
</Button>
</button>
)
)}
</>
@@ -639,14 +633,18 @@ export function LobbyPage() {
return (
<div className="max-w-4xl mx-auto">
{/* Back button */}
<Link to={`/marathons/${id}`} className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
<ArrowLeft className="w-4 h-4" />
<Link
to={`/marathons/${id}`}
className="inline-flex items-center gap-2 text-gray-400 hover:text-neon-400 mb-6 transition-colors group"
>
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
К марафону
</Link>
<div className="flex justify-between items-center mb-8">
{/* Header */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
<div>
<h1 className="text-2xl font-bold text-white">{marathon.title}</h1>
<h1 className="text-2xl font-bold text-white mb-1">{marathon.title}</h1>
<p className="text-gray-400">
{isOrganizer
? 'Настройка - Добавьте игры и сгенерируйте задания'
@@ -655,296 +653,306 @@ export function LobbyPage() {
</div>
{isOrganizer && (
<Button onClick={handleStartMarathon} isLoading={isStarting} disabled={approvedGames.length === 0}>
<Play className="w-4 h-4 mr-2" />
<NeonButton
onClick={handleStartMarathon}
isLoading={isStarting}
disabled={approvedGames.length === 0}
icon={<Play className="w-4 h-4" />}
>
Запустить марафон
</Button>
</NeonButton>
)}
</div>
{/* Stats - только для организаторов */}
{/* Stats */}
{isOrganizer && (
<div className="grid grid-cols-2 gap-4 mb-8">
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">{approvedGames.length}</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<Gamepad2 className="w-4 h-4" />
Игр одобрено
</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">{totalChallenges}</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<Sparkles className="w-4 h-4" />
Заданий
</div>
</CardContent>
</Card>
<StatsCard
label="Игр одобрено"
value={approvedGames.length}
icon={<Gamepad2 className="w-6 h-6" />}
color="neon"
/>
<StatsCard
label="Заданий"
value={totalChallenges}
icon={<Sparkles className="w-6 h-6" />}
color="purple"
/>
</div>
)}
{/* Pending games for moderation (organizers only) */}
{/* Pending games for moderation */}
{isOrganizer && pendingGames.length > 0 && (
<Card className="mb-8 border-yellow-900/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-yellow-400">
<Clock className="w-5 h-5" />
На модерации ({pendingGames.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{pendingGames.map((game) => renderGameCard(game, true))}
<GlassCard className="mb-8 border-yellow-500/30">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center">
<Clock className="w-5 h-5 text-yellow-400" />
</div>
</CardContent>
</Card>
<div>
<h3 className="font-semibold text-yellow-400">На модерации</h3>
<p className="text-sm text-gray-400">{pendingGames.length} игр ожидают</p>
</div>
</div>
<div className="space-y-3">
{pendingGames.map((game) => renderGameCard(game, true))}
</div>
</GlassCard>
)}
{/* Generate challenges button */}
{/* Generate challenges */}
{isOrganizer && approvedGames.length > 0 && !previewChallenges && (
<Card className="mb-8">
<CardContent>
<div className="flex items-center justify-between">
<GlassCard className="mb-8">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
<Zap className="w-5 h-5 text-accent-400" />
</div>
<div>
<h3 className="font-medium text-white">Генерация заданий</h3>
<h3 className="font-semibold text-white">Генерация заданий</h3>
<p className="text-sm text-gray-400">
Используйте ИИ для генерации заданий для одобренных игр без заданий
ИИ создаст задания для игр без заданий
</p>
</div>
<Button onClick={handleGenerateChallenges} isLoading={isGenerating} variant="secondary">
<Sparkles className="w-4 h-4 mr-2" />
Сгенерировать
</Button>
</div>
{generateMessage && (
<p className="mt-3 text-sm text-primary-400">{generateMessage}</p>
)}
</CardContent>
</Card>
<NeonButton
onClick={handleGenerateChallenges}
isLoading={isGenerating}
variant="outline"
color="purple"
icon={<Sparkles className="w-4 h-4" />}
>
Сгенерировать
</NeonButton>
</div>
{generateMessage && (
<p className="mt-4 text-sm text-neon-400 p-3 bg-neon-500/10 rounded-lg border border-neon-500/20">
{generateMessage}
</p>
)}
</GlassCard>
)}
{/* Challenge preview with editing */}
{/* Challenge preview */}
{previewChallenges && previewChallenges.length > 0 && (
<Card className="mb-8">
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-2">
<Eye className="w-5 h-5 text-primary-400" />
<CardTitle>Предпросмотр заданий ({previewChallenges.length})</CardTitle>
<GlassCard className="mb-8 border-accent-500/30">
<div className="flex items-center justify-between mb-4 flex-wrap gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
<Eye className="w-5 h-5 text-accent-400" />
</div>
<div>
<h3 className="font-semibold text-white">Предпросмотр заданий</h3>
<p className="text-sm text-gray-400">{previewChallenges.length} заданий</p>
</div>
</div>
<div className="flex gap-2">
<Button onClick={handleCancelPreview} variant="ghost" size="sm">
<X className="w-4 h-4 mr-1" />
<NeonButton onClick={handleCancelPreview} variant="outline" size="sm" icon={<X className="w-4 h-4" />}>
Отмена
</Button>
<Button onClick={handleSaveChallenges} isLoading={isSaving} size="sm">
<Save className="w-4 h-4 mr-1" />
</NeonButton>
<NeonButton onClick={handleSaveChallenges} isLoading={isSaving} size="sm" icon={<Save className="w-4 h-4" />}>
Сохранить все
</Button>
</NeonButton>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3 max-h-[60vh] overflow-y-auto">
{previewChallenges.map((challenge, index) => (
<div
key={index}
className="p-4 bg-gray-900 rounded-lg border border-gray-800"
>
{editingIndex === index ? (
// Edit mode
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-xs px-2 py-0.5 rounded bg-gray-800 text-gray-400">
</div>
<div className="space-y-3 max-h-[60vh] overflow-y-auto custom-scrollbar">
{previewChallenges.map((challenge, index) => (
<div
key={index}
className="p-4 bg-dark-700/50 rounded-xl border border-dark-600"
>
{editingIndex === index ? (
<div className="space-y-3">
<span className="text-xs px-2 py-0.5 rounded-lg bg-dark-600 text-gray-400">
{challenge.game_title}
</span>
<Input
value={challenge.title}
onChange={(e) => handleUpdatePreviewChallenge(index, 'title', e.target.value)}
placeholder="Название"
/>
<textarea
value={challenge.description}
onChange={(e) => handleUpdatePreviewChallenge(index, 'description', e.target.value)}
placeholder="Описание"
className="input w-full resize-none"
rows={2}
/>
<div className="grid grid-cols-3 gap-2">
<select
value={challenge.difficulty}
onChange={(e) => handleUpdatePreviewChallenge(index, 'difficulty', e.target.value)}
className="input"
>
<option value="easy">Легко</option>
<option value="medium">Средне</option>
<option value="hard">Сложно</option>
</select>
<Input
type="number"
value={challenge.points}
onChange={(e) => handleUpdatePreviewChallenge(index, 'points', parseInt(e.target.value) || 0)}
placeholder="Очки"
/>
<select
value={challenge.proof_type}
onChange={(e) => handleUpdatePreviewChallenge(index, 'proof_type', e.target.value)}
className="input"
>
<option value="screenshot">Скриншот</option>
<option value="video">Видео</option>
<option value="steam">Steam</option>
</select>
</div>
<Input
value={challenge.proof_hint || ''}
onChange={(e) => handleUpdatePreviewChallenge(index, 'proof_hint', e.target.value)}
placeholder="Подсказка для пруфа"
/>
<div className="flex gap-2">
<NeonButton size="sm" onClick={() => setEditingIndex(null)} icon={<Check className="w-4 h-4" />}>
Готово
</NeonButton>
<NeonButton
variant="outline"
size="sm"
onClick={() => handleRemovePreviewChallenge(index)}
className="text-red-400 border-red-500/30 hover:bg-red-500/10"
icon={<Trash2 className="w-4 h-4" />}
>
Удалить
</NeonButton>
</div>
</div>
) : (
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-xs px-2 py-0.5 rounded-lg bg-dark-600 text-gray-400">
{challenge.game_title}
</span>
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
'bg-red-500/20 text-red-400 border-red-500/30'
}`}>
{challenge.difficulty === 'easy' ? 'Легко' :
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
</span>
<span className="text-xs text-neon-400 font-semibold">
+{challenge.points} очков
</span>
</div>
<Input
value={challenge.title}
onChange={(e) => handleUpdatePreviewChallenge(index, 'title', e.target.value)}
placeholder="Название"
className="bg-gray-800"
/>
<textarea
value={challenge.description}
onChange={(e) => handleUpdatePreviewChallenge(index, 'description', e.target.value)}
placeholder="Описание"
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm resize-none"
rows={2}
/>
<div className="grid grid-cols-3 gap-2">
<select
value={challenge.difficulty}
onChange={(e) => handleUpdatePreviewChallenge(index, 'difficulty', e.target.value)}
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm"
>
<option value="easy">Легко</option>
<option value="medium">Средне</option>
<option value="hard">Сложно</option>
</select>
<Input
type="number"
value={challenge.points}
onChange={(e) => handleUpdatePreviewChallenge(index, 'points', parseInt(e.target.value) || 0)}
placeholder="Очки"
className="bg-gray-800"
/>
<select
value={challenge.proof_type}
onChange={(e) => handleUpdatePreviewChallenge(index, 'proof_type', e.target.value)}
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm"
>
<option value="screenshot">Скриншот</option>
<option value="video">Видео</option>
<option value="steam">Steam</option>
</select>
</div>
<Input
value={challenge.proof_hint || ''}
onChange={(e) => handleUpdatePreviewChallenge(index, 'proof_hint', e.target.value)}
placeholder="Подсказка для подтверждения"
className="bg-gray-800"
/>
<div className="flex gap-2">
<Button size="sm" onClick={() => setEditingIndex(null)}>
<Check className="w-4 h-4 mr-1" />
Готово
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemovePreviewChallenge(index)}
className="text-red-400 hover:text-red-300"
>
<Trash2 className="w-4 h-4 mr-1" />
Удалить
</Button>
</div>
<h4 className="font-medium text-white mb-1">{challenge.title}</h4>
<p className="text-sm text-gray-400">{challenge.description}</p>
{challenge.proof_hint && (
<p className="text-xs text-gray-500 mt-2">
Пруф: {challenge.proof_hint}
</p>
)}
</div>
) : (
// View mode
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs px-2 py-0.5 rounded bg-gray-800 text-gray-400">
{challenge.game_title}
</span>
<span className={`text-xs px-2 py-0.5 rounded ${
challenge.difficulty === 'easy' ? 'bg-green-900/50 text-green-400' :
challenge.difficulty === 'medium' ? 'bg-yellow-900/50 text-yellow-400' :
'bg-red-900/50 text-red-400'
}`}>
{challenge.difficulty === 'easy' ? 'Легко' :
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
</span>
<span className="text-xs text-primary-400 font-medium">
+{challenge.points} очков
</span>
</div>
<h4 className="font-medium text-white mb-1">{challenge.title}</h4>
<p className="text-sm text-gray-400">{challenge.description}</p>
{challenge.proof_hint && (
<p className="text-xs text-gray-500 mt-2">
Подтверждение: {challenge.proof_hint}
</p>
)}
</div>
<div className="flex gap-1 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => setEditingIndex(index)}
className="text-gray-400 hover:text-white"
>
<Edit2 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemovePreviewChallenge(index)}
className="text-red-400 hover:text-red-300"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="flex gap-1 shrink-0">
<button
onClick={() => setEditingIndex(index)}
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleRemovePreviewChallenge(index)}
className="p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
)}
</div>
))}
</div>
</CardContent>
</Card>
</div>
)}
</div>
))}
</div>
</GlassCard>
)}
{/* Games list */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Игры</CardTitle>
{/* Показываем кнопку если: all_participants ИЛИ (organizer_only И isOrganizer) */}
{(marathon.game_proposal_mode === 'all_participants' || isOrganizer) && (
<Button size="sm" onClick={() => setShowAddGame(!showAddGame)}>
<Plus className="w-4 h-4 mr-1" />
{isOrganizer ? 'Добавить игру' : 'Предложить игру'}
</Button>
)}
</CardHeader>
<CardContent>
{/* Add game form */}
{showAddGame && (
<div className="mb-6 p-4 bg-gray-900 rounded-lg space-y-3">
<Input
placeholder="Название игры"
value={gameTitle}
onChange={(e) => setGameTitle(e.target.value)}
/>
<Input
placeholder="Ссылка для скачивания"
value={gameUrl}
onChange={(e) => setGameUrl(e.target.value)}
/>
<Input
placeholder="Жанр (необязательно)"
value={gameGenre}
onChange={(e) => setGameGenre(e.target.value)}
/>
<div className="flex gap-2">
<Button onClick={handleAddGame} isLoading={isAddingGame} disabled={!gameTitle || !gameUrl}>
{isOrganizer ? 'Добавить' : 'Предложить'}
</Button>
<Button variant="ghost" onClick={() => setShowAddGame(false)}>
Отмена
</Button>
</div>
{!isOrganizer && (
<p className="text-xs text-gray-500">
Ваша игра будет отправлена на модерацию организаторам
</p>
)}
<GlassCard>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-neon-500/20 flex items-center justify-center">
<Gamepad2 className="w-5 h-5 text-neon-400" />
</div>
<h3 className="font-semibold text-white">Игры</h3>
</div>
{(marathon.game_proposal_mode === 'all_participants' || isOrganizer) && (
<NeonButton
size="sm"
onClick={() => setShowAddGame(!showAddGame)}
icon={<Plus className="w-4 h-4" />}
>
{isOrganizer ? 'Добавить' : 'Предложить'}
</NeonButton>
)}
</div>
{/* Games */}
{(() => {
// Организаторы: показываем только одобренные (pending в секции модерации)
// Участники: показываем одобренные + свои pending
const visibleGames = isOrganizer
? games.filter(g => g.status !== 'pending')
: games.filter(g => g.status === 'approved' || (g.status === 'pending' && g.proposed_by?.id === user?.id))
{/* Add game form */}
{showAddGame && (
<div className="mb-6 p-4 glass rounded-xl space-y-3 border border-neon-500/20">
<Input
placeholder="Название игры"
value={gameTitle}
onChange={(e) => setGameTitle(e.target.value)}
/>
<Input
placeholder="Ссылка для скачивания"
value={gameUrl}
onChange={(e) => setGameUrl(e.target.value)}
/>
<Input
placeholder="Жанр (необязательно)"
value={gameGenre}
onChange={(e) => setGameGenre(e.target.value)}
/>
<div className="flex gap-2">
<NeonButton
onClick={handleAddGame}
isLoading={isAddingGame}
disabled={!gameTitle || !gameUrl}
>
{isOrganizer ? 'Добавить' : 'Предложить'}
</NeonButton>
<NeonButton variant="outline" onClick={() => setShowAddGame(false)}>
Отмена
</NeonButton>
</div>
{!isOrganizer && (
<p className="text-xs text-gray-500">
Игра будет отправлена на модерацию организаторам
</p>
)}
</div>
)}
return visibleGames.length === 0 ? (
<p className="text-center text-gray-400 py-8">
{/* Games */}
{(() => {
const visibleGames = isOrganizer
? games.filter(g => g.status !== 'pending')
: games.filter(g => g.status === 'approved' || (g.status === 'pending' && g.proposed_by?.id === user?.id))
return visibleGames.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-700 flex items-center justify-center">
<Gamepad2 className="w-8 h-8 text-gray-600" />
</div>
<p className="text-gray-400">
{isOrganizer ? 'Пока нет игр. Добавьте игры, чтобы начать!' : 'Пока нет одобренных игр. Предложите свою!'}
</p>
) : (
<div className="space-y-3">
{visibleGames.map((game) => renderGameCard(game, false))}
</div>
)
})()}
</CardContent>
</Card>
</div>
) : (
<div className="space-y-3">
{visibleGames.map((game) => renderGameCard(game, false))}
</div>
)
})()}
</GlassCard>
</div>
)
}

View File

@@ -5,7 +5,8 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useAuthStore } from '@/store/auth'
import { marathonsApi } from '@/api'
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui'
import { NeonButton, Input } from '@/components/ui'
import { Gamepad2, LogIn, AlertCircle } from 'lucide-react'
const loginSchema = z.object({
login: z.string().min(3, 'Логин должен быть не менее 3 символов'),
@@ -52,16 +53,33 @@ export function LoginPage() {
}
return (
<div className="max-w-md mx-auto">
<Card>
<CardHeader>
<CardTitle className="text-center">Вход</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="min-h-[80vh] flex items-center justify-center px-4 -mt-8">
{/* Background effects */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 -left-32 w-96 h-96 bg-neon-500/10 rounded-full blur-[100px]" />
<div className="absolute bottom-1/4 -right-32 w-96 h-96 bg-accent-500/10 rounded-full blur-[100px]" />
</div>
<div className="relative w-full max-w-md">
{/* Card */}
<div className="glass-neon rounded-2xl p-8 animate-scale-in">
{/* Header */}
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 rounded-2xl bg-neon-500/10 border border-neon-500/30 flex items-center justify-center">
<Gamepad2 className="w-8 h-8 text-neon-500" />
</div>
</div>
<h1 className="text-2xl font-bold text-white mb-2">Добро пожаловать!</h1>
<p className="text-gray-400">Войдите, чтобы продолжить</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{(submitError || error) && (
<div className="p-3 bg-red-500/20 border border-red-500 rounded-lg text-red-500 text-sm">
{submitError || error}
<div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span>{submitError || error}</span>
</div>
)}
@@ -69,6 +87,7 @@ export function LoginPage() {
label="Логин"
placeholder="Введите логин"
error={errors.login?.message}
autoComplete="username"
{...register('login')}
/>
@@ -77,22 +96,39 @@ export function LoginPage() {
type="password"
placeholder="Введите пароль"
error={errors.password?.message}
autoComplete="current-password"
{...register('password')}
/>
<Button type="submit" className="w-full" isLoading={isLoading}>
<NeonButton
type="submit"
className="w-full"
size="lg"
isLoading={isLoading}
icon={<LogIn className="w-5 h-5" />}
>
Войти
</Button>
</NeonButton>
</form>
<p className="text-center text-gray-400 text-sm">
{/* Footer */}
<div className="mt-6 pt-6 border-t border-dark-600 text-center">
<p className="text-gray-400 text-sm">
Нет аккаунта?{' '}
<Link to="/register" className="link">
<Link
to="/register"
className="text-neon-400 hover:text-neon-300 transition-colors font-medium"
>
Зарегистрироваться
</Link>
</p>
</form>
</CardContent>
</Card>
</div>
</div>
{/* Decorative elements */}
<div className="absolute -top-4 -right-4 w-24 h-24 border border-neon-500/20 rounded-2xl -z-10" />
<div className="absolute -bottom-4 -left-4 w-32 h-32 border border-accent-500/20 rounded-2xl -z-10" />
</div>
</div>
)
}

View File

@@ -2,15 +2,20 @@ 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 { Button, Card, CardContent } from '@/components/ui'
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
import { EventBanner } from '@/components/EventBanner'
import { EventControl } from '@/components/EventControl'
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
import { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2, Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag } from 'lucide-react'
import {
Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2,
Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag,
Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2
} from 'lucide-react'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
export function MarathonPage() {
const { id } = useParams<{ id: string }>()
@@ -39,12 +44,10 @@ export function MarathonPage() {
const data = await marathonsApi.get(parseInt(id))
setMarathon(data)
// Load event data if marathon is active
if (data.status === 'active' && data.my_participation) {
const eventData = await eventsApi.getActive(parseInt(id))
setActiveEvent(eventData)
// Load challenges for event control if organizer
if (data.my_participation.role === 'organizer') {
try {
const challengesData = await challengesApi.list(parseInt(id))
@@ -67,7 +70,6 @@ export function MarathonPage() {
try {
const eventData = await eventsApi.getActive(parseInt(id))
setActiveEvent(eventData)
// Refresh activity feed when event changes
activityFeedRef.current?.refresh()
} catch (error) {
console.error('Failed to refresh event:', error)
@@ -153,8 +155,9 @@ export function MarathonPage() {
if (isLoading || !marathon) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
<div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка марафона...</p>
</div>
)
}
@@ -164,265 +167,257 @@ export function MarathonPage() {
const isCreator = marathon.creator.id === user?.id
const canDelete = isCreator || user?.role === 'admin'
const statusConfig = {
active: { color: 'text-neon-400', bg: 'bg-neon-500/20', border: 'border-neon-500/30', label: 'Активен' },
preparing: { color: 'text-yellow-400', bg: 'bg-yellow-500/20', border: 'border-yellow-500/30', label: 'Подготовка' },
finished: { color: 'text-gray-400', bg: 'bg-gray-500/20', border: 'border-gray-500/30', label: 'Завершён' },
}
const status = statusConfig[marathon.status as keyof typeof statusConfig] || statusConfig.finished
return (
<div className="max-w-7xl mx-auto">
{/* Back button */}
<Link to="/marathons" className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
<ArrowLeft className="w-4 h-4" />
<Link
to="/marathons"
className="inline-flex items-center gap-2 text-gray-400 hover:text-neon-400 mb-6 transition-colors group"
>
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
К списку марафонов
</Link>
<div className="flex flex-col lg:flex-row gap-6">
{/* Main content */}
<div className="flex-1 min-w-0">
{/* Header */}
<div className="flex justify-between items-start mb-8">
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-3xl font-bold text-white">{marathon.title}</h1>
<span className={`flex items-center gap-1 text-xs px-2 py-1 rounded ${
{/* Hero Banner */}
<div className="relative rounded-2xl overflow-hidden mb-8">
{/* Background */}
<div className="absolute inset-0 bg-gradient-to-br from-neon-500/10 via-dark-800 to-accent-500/10" />
<div className="absolute inset-0 bg-[linear-gradient(rgba(0,240,255,0.03)_1px,transparent_1px),linear-gradient(90deg,rgba(0,240,255,0.03)_1px,transparent_1px)] bg-[size:50px_50px]" />
<div className="relative p-8">
<div className="flex flex-col md:flex-row justify-between items-start gap-6">
{/* Title & Description */}
<div className="flex-1">
<div className="flex flex-wrap items-center gap-3 mb-3">
<h1 className="text-3xl md:text-4xl font-bold text-white">{marathon.title}</h1>
<span className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border ${
marathon.is_public
? 'bg-green-900/50 text-green-400'
: 'bg-gray-700 text-gray-300'
? 'bg-green-500/20 text-green-400 border-green-500/30'
: 'bg-dark-700 text-gray-300 border-dark-600'
}`}>
{marathon.is_public ? (
<><Globe className="w-3 h-3" /> Открытый</>
) : (
<><Lock className="w-3 h-3" /> Закрытый</>
)}
{marathon.is_public ? <Globe className="w-3 h-3" /> : <Lock className="w-3 h-3" />}
{marathon.is_public ? 'Открытый' : 'Закрытый'}
</span>
<span className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border ${status.bg} ${status.color} ${status.border}`}>
<span className={`w-2 h-2 rounded-full ${marathon.status === 'active' ? 'bg-neon-500 animate-pulse' : marathon.status === 'preparing' ? 'bg-yellow-500' : 'bg-gray-500'}`} />
{status.label}
</span>
</div>
{marathon.description && (
<p className="text-gray-400">{marathon.description}</p>
<p className="text-gray-400 max-w-2xl">{marathon.description}</p>
)}
</div>
<div className="flex gap-2 flex-wrap justify-end">
{/* Кнопка присоединиться для открытых марафонов */}
{/* Action Buttons */}
<div className="flex flex-wrap gap-2">
{marathon.is_public && !isParticipant && marathon.status !== 'finished' && (
<Button onClick={handleJoinPublic} isLoading={isJoining}>
<UserPlus className="w-4 h-4 mr-2" />
<NeonButton onClick={handleJoinPublic} isLoading={isJoining} icon={<UserPlus className="w-4 h-4" />}>
Присоединиться
</Button>
</NeonButton>
)}
{/* Настройка для организаторов */}
{marathon.status === 'preparing' && isOrganizer && (
<Link to={`/marathons/${id}/lobby`}>
<Button variant="secondary">
<Settings className="w-4 h-4 mr-2" />
<NeonButton variant="secondary" icon={<Settings className="w-4 h-4" />}>
Настройка
</Button>
</NeonButton>
</Link>
)}
{/* Предложить игру для участников (не организаторов) если разрешено */}
{marathon.status === 'preparing' && isParticipant && !isOrganizer && marathon.game_proposal_mode === 'all_participants' && (
<Link to={`/marathons/${id}/lobby`}>
<Button variant="secondary">
<Gamepad2 className="w-4 h-4 mr-2" />
<NeonButton variant="secondary" icon={<Gamepad2 className="w-4 h-4" />}>
Предложить игру
</Button>
</NeonButton>
</Link>
)}
{marathon.status === 'active' && isParticipant && (
<Link to={`/marathons/${id}/play`}>
<Button>
<Play className="w-4 h-4 mr-2" />
<NeonButton icon={<Play className="w-4 h-4" />}>
Играть
</Button>
</NeonButton>
</Link>
)}
<Link to={`/marathons/${id}/leaderboard`}>
<Button variant="secondary">
<Trophy className="w-4 h-4 mr-2" />
<NeonButton variant="outline" icon={<Trophy className="w-4 h-4" />}>
Рейтинг
</Button>
</NeonButton>
</Link>
{marathon.status === 'active' && isOrganizer && (
<Button
<NeonButton
variant="secondary"
onClick={handleFinish}
isLoading={isFinishing}
className="text-yellow-400 hover:text-yellow-300 hover:bg-yellow-900/20"
icon={<Flag className="w-4 h-4" />}
className="!text-yellow-400 !border-yellow-500/30 hover:!bg-yellow-500/10"
>
<Flag className="w-4 h-4 mr-2" />
Завершить
</Button>
</NeonButton>
)}
{canDelete && (
<Button
<NeonButton
variant="ghost"
onClick={handleDelete}
isLoading={isDeleting}
className="text-red-400 hover:text-red-300 hover:bg-red-900/20"
>
<Trash2 className="w-4 h-4" />
</Button>
className="!text-red-400 hover:!bg-red-500/10"
icon={<Trash2 className="w-4 h-4" />}
/>
)}
</div>
</div>
</div>
</div>
<div className="flex flex-col lg:flex-row gap-6">
{/* Main content */}
<div className="flex-1 min-w-0 space-y-6">
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">{marathon.participants_count}</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<Users className="w-4 h-4" />
Участников
</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">{marathon.games_count}</div>
<div className="text-sm text-gray-400">Игр</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">
{marathon.start_date ? format(new Date(marathon.start_date), 'd MMM') : '-'}
</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<Calendar className="w-4 h-4" />
Начало
</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className="text-2xl font-bold text-white">
{marathon.end_date ? format(new Date(marathon.end_date), 'd MMM') : '-'}
</div>
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
<CalendarCheck className="w-4 h-4" />
Конец
</div>
</CardContent>
</Card>
<Card>
<CardContent className="text-center py-4">
<div className={`text-2xl font-bold ${
marathon.status === 'active' ? 'text-green-500' :
marathon.status === 'preparing' ? 'text-yellow-500' : 'text-gray-400'
}`}>
{marathon.status === 'active' ? 'Активен' : marathon.status === 'preparing' ? 'Подготовка' : 'Завершён'}
</div>
<div className="text-sm text-gray-400">Статус</div>
</CardContent>
</Card>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<StatsCard
label="Участников"
value={marathon.participants_count}
icon={<Users className="w-5 h-5" />}
color="neon"
/>
<StatsCard
label="Игр"
value={marathon.games_count}
icon={<Gamepad2 className="w-5 h-5" />}
color="purple"
/>
<StatsCard
label="Начало"
value={marathon.start_date ? format(new Date(marathon.start_date), 'd MMM', { locale: ru }) : '-'}
icon={<Calendar className="w-5 h-5" />}
color="default"
/>
<StatsCard
label="Конец"
value={marathon.end_date ? format(new Date(marathon.end_date), 'd MMM', { locale: ru }) : '-'}
icon={<CalendarCheck className="w-5 h-5" />}
color="default"
/>
<StatsCard
label="Статус"
value={status.label}
icon={<Target className="w-5 h-5" />}
color={marathon.status === 'active' ? 'neon' : marathon.status === 'preparing' ? 'default' : 'default'}
/>
</div>
{/* Active event banner */}
{marathon.status === 'active' && activeEvent?.event && (
<div className="mb-8">
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
</div>
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
)}
{/* Event control for organizers */}
{marathon.status === 'active' && isOrganizer && (
<Card className="mb-8">
<CardContent>
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium text-white flex items-center gap-2">
<Zap className="w-5 h-5 text-yellow-500" />
Управление событиями
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowEventControl(!showEventControl)}
>
{showEventControl ? 'Скрыть' : 'Показать'}
</Button>
<GlassCard>
<button
onClick={() => setShowEventControl(!showEventControl)}
className="w-full flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center">
<Zap className="w-5 h-5 text-yellow-400" />
</div>
<div className="text-left">
<h3 className="font-semibold text-white">Управление событиями</h3>
<p className="text-sm text-gray-400">Активируйте бонусы для участников</p>
</div>
</div>
{showEventControl && activeEvent && (
{showEventControl ? (
<ChevronUp className="w-5 h-5 text-gray-400" />
) : (
<ChevronDown className="w-5 h-5 text-gray-400" />
)}
</button>
{showEventControl && activeEvent && (
<div className="mt-6 pt-6 border-t border-dark-600">
<EventControl
marathonId={marathon.id}
activeEvent={activeEvent}
challenges={challenges}
onEventChange={refreshEvent}
/>
)}
</CardContent>
</Card>
</div>
)}
</GlassCard>
)}
{/* Invite link */}
{marathon.status !== 'finished' && (
<Card className="mb-8">
<CardContent>
<h3 className="font-medium text-white mb-3">Ссылка для приглашения</h3>
<div className="flex items-center gap-3">
<code className="flex-1 px-4 py-2 bg-gray-900 rounded-lg text-primary-400 font-mono text-sm overflow-hidden text-ellipsis">
{getInviteLink()}
</code>
<Button variant="secondary" onClick={copyInviteLink}>
{copied ? (
<>
<Check className="w-4 h-4 mr-2" />
Скопировано!
</>
) : (
<>
<Copy className="w-4 h-4 mr-2" />
Копировать
</>
)}
</Button>
<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">
<Link2 className="w-5 h-5 text-accent-400" />
</div>
<p className="text-sm text-gray-500 mt-2">
Поделитесь этой ссылкой с друзьями, чтобы они могли присоединиться к марафону
</p>
</CardContent>
</Card>
<div>
<h3 className="font-semibold text-white">Пригласить друзей</h3>
<p className="text-sm text-gray-400">Поделитесь ссылкой</p>
</div>
</div>
<div className="flex items-center gap-3">
<code className="flex-1 px-4 py-3 bg-dark-700 rounded-xl text-neon-400 font-mono text-sm overflow-hidden text-ellipsis border border-dark-600">
{getInviteLink()}
</code>
<NeonButton variant="secondary" onClick={copyInviteLink} icon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}>
{copied ? 'Скопировано!' : 'Копировать'}
</NeonButton>
</div>
</GlassCard>
)}
{/* My stats */}
{marathon.my_participation && (
<Card>
<CardContent>
<h3 className="font-medium text-white mb-4">Ваша статистика</h3>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-primary-500">
{marathon.my_participation.total_points}
</div>
<div className="text-sm text-gray-400">Очков</div>
</div>
<div>
<div className="text-2xl font-bold text-yellow-500">
{marathon.my_participation.current_streak}
</div>
<div className="text-sm text-gray-400">Серия</div>
</div>
<div>
<div className="text-2xl font-bold text-gray-400">
{marathon.my_participation.drop_count}
</div>
<div className="text-sm text-gray-400">Пропусков</div>
<GlassCard variant="neon">
<h3 className="font-semibold text-white mb-4 flex items-center gap-2">
<Star className="w-5 h-5 text-yellow-500" />
Ваша статистика
</h3>
<div className="grid grid-cols-3 gap-4">
<div className="text-center p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="text-3xl font-bold text-neon-400">
{marathon.my_participation.total_points}
</div>
<div className="text-sm text-gray-400 mt-1">Очков</div>
</div>
</CardContent>
</Card>
<div className="text-center p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="text-3xl font-bold text-yellow-400 flex items-center justify-center gap-1">
{marathon.my_participation.current_streak}
{marathon.my_participation.current_streak > 0 && (
<span className="text-lg">🔥</span>
)}
</div>
<div className="text-sm text-gray-400 mt-1">Серия</div>
</div>
<div className="text-center p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="text-3xl font-bold text-gray-400 flex items-center justify-center gap-1">
{marathon.my_participation.drop_count}
<TrendingDown className="w-5 h-5" />
</div>
<div className="text-sm text-gray-400 mt-1">Пропусков</div>
</div>
</div>
</GlassCard>
)}
</div>
{/* Activity Feed - right sidebar */}
{isParticipant && (
<div className="lg:w-96 flex-shrink-0">
<div className="lg:sticky lg:top-4">
<div className="lg:sticky lg:top-24">
<ActivityFeed
ref={activityFeedRef}
marathonId={marathon.id}

View File

@@ -2,9 +2,10 @@ import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { marathonsApi } from '@/api'
import type { MarathonListItem } from '@/types'
import { Button, Card, CardContent } from '@/components/ui'
import { Plus, Users, Calendar, Loader2 } from 'lucide-react'
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { Plus, Users, Calendar, Loader2, Trophy, Gamepad2, ChevronRight, Hash, Sparkles } from 'lucide-react'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
export function MarathonsPage() {
const [marathons, setMarathons] = useState<MarathonListItem[]>([])
@@ -12,6 +13,7 @@ export function MarathonsPage() {
const [joinCode, setJoinCode] = useState('')
const [joinError, setJoinError] = useState<string | null>(null)
const [isJoining, setIsJoining] = useState(false)
const [showJoinSection, setShowJoinSection] = useState(false)
useEffect(() => {
loadMarathons()
@@ -36,6 +38,7 @@ export function MarathonsPage() {
try {
await marathonsApi.join(joinCode.trim())
setJoinCode('')
setShowJoinSection(false)
await loadMarathons()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
@@ -45,112 +48,217 @@ export function MarathonsPage() {
}
}
const getStatusColor = (status: string) => {
const getStatusConfig = (status: string) => {
switch (status) {
case 'preparing':
return 'bg-yellow-500/20 text-yellow-500'
return {
color: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
text: 'Подготовка',
dot: 'bg-yellow-500',
}
case 'active':
return 'bg-green-500/20 text-green-500'
return {
color: 'bg-neon-500/20 text-neon-400 border-neon-500/30',
text: 'Активен',
dot: 'bg-neon-500 animate-pulse',
}
case 'finished':
return 'bg-gray-500/20 text-gray-400'
return {
color: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
text: 'Завершён',
dot: 'bg-gray-500',
}
default:
return 'bg-gray-500/20 text-gray-400'
return {
color: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
text: status,
dot: 'bg-gray-500',
}
}
}
const getStatusText = (status: string) => {
switch (status) {
case 'preparing':
return 'Подготовка'
case 'active':
return 'Активен'
case 'finished':
return 'Завершён'
default:
return status
}
}
// Stats
const activeCount = marathons.filter(m => m.status === 'active').length
const completedCount = marathons.filter(m => m.status === 'finished').length
const totalParticipants = marathons.reduce((acc, m) => acc + m.participants_count, 0)
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
<div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка марафонов...</p>
</div>
)
}
return (
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold text-white">Мои марафоны</h1>
<Link to="/marathons/create">
<Button>
<Plus className="w-4 h-4 mr-2" />
Создать марафон
</Button>
</Link>
<div className="max-w-5xl mx-auto">
{/* Header */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Мои марафоны</h1>
<p className="text-gray-400">Управляйте своими игровыми соревнованиями</p>
</div>
<div className="flex gap-3">
<NeonButton
variant="outline"
onClick={() => setShowJoinSection(!showJoinSection)}
icon={<Hash className="w-4 h-4" />}
>
По коду
</NeonButton>
<Link to="/marathons/create">
<NeonButton icon={<Plus className="w-4 h-4" />}>
Создать
</NeonButton>
</Link>
</div>
</div>
{/* Stats */}
{marathons.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<StatsCard
label="Всего"
value={marathons.length}
icon={<Gamepad2 className="w-6 h-6" />}
color="default"
/>
<StatsCard
label="Активных"
value={activeCount}
icon={<Sparkles className="w-6 h-6" />}
color="neon"
/>
<StatsCard
label="Завершено"
value={completedCount}
icon={<Trophy className="w-6 h-6" />}
color="purple"
/>
<StatsCard
label="Участников"
value={totalParticipants}
icon={<Users className="w-6 h-6" />}
color="pink"
/>
</div>
)}
{/* Join marathon */}
<Card className="mb-8">
<CardContent>
<h3 className="font-medium text-white mb-3">Присоединиться к марафону</h3>
{showJoinSection && (
<GlassCard className="mb-8 animate-slide-in-down" variant="neon">
<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">
<Hash className="w-5 h-5 text-accent-400" />
</div>
<div>
<h3 className="font-semibold text-white">Присоединиться к марафону</h3>
<p className="text-sm text-gray-400">Введите код приглашения</p>
</div>
</div>
<div className="flex gap-3">
<input
type="text"
value={joinCode}
onChange={(e) => setJoinCode(e.target.value)}
placeholder="Введите код приглашения"
className="input flex-1"
onChange={(e) => setJoinCode(e.target.value.toUpperCase())}
onKeyDown={(e) => e.key === 'Enter' && handleJoin()}
placeholder="XXXXXX"
className="input flex-1 font-mono text-center tracking-widest uppercase"
maxLength={10}
/>
<Button onClick={handleJoin} isLoading={isJoining}>
<NeonButton
onClick={handleJoin}
isLoading={isJoining}
color="purple"
>
Присоединиться
</Button>
</NeonButton>
</div>
{joinError && <p className="mt-2 text-sm text-red-500">{joinError}</p>}
</CardContent>
</Card>
{joinError && (
<p className="mt-3 text-sm text-red-400 flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-red-500 rounded-full" />
{joinError}
</p>
)}
</GlassCard>
)}
{/* Marathon list */}
{marathons.length === 0 ? (
<Card>
<CardContent className="text-center py-8">
<p className="text-gray-400 mb-4">У вас пока нет марафонов</p>
<GlassCard className="text-center py-16">
<div className="w-20 h-20 mx-auto mb-6 rounded-2xl bg-dark-700 flex items-center justify-center">
<Gamepad2 className="w-10 h-10 text-gray-600" />
</div>
<h3 className="text-xl font-semibold text-white mb-2">Нет марафонов</h3>
<p className="text-gray-400 mb-6 max-w-sm mx-auto">
Создайте свой первый марафон или присоединитесь к существующему по коду
</p>
<div className="flex gap-3 justify-center">
<NeonButton
variant="outline"
onClick={() => setShowJoinSection(true)}
icon={<Hash className="w-4 h-4" />}
>
Ввести код
</NeonButton>
<Link to="/marathons/create">
<Button>Создать первый марафон</Button>
<NeonButton icon={<Plus className="w-4 h-4" />}>
Создать марафон
</NeonButton>
</Link>
</CardContent>
</Card>
</div>
</GlassCard>
) : (
<div className="space-y-4">
{marathons.map((marathon) => (
<Link key={marathon.id} to={`/marathons/${marathon.id}`}>
<Card className="hover:bg-gray-700/50 transition-colors cursor-pointer">
<CardContent className="flex items-center justify-between">
<div>
<h3 className="text-lg font-medium text-white mb-1">
{marathon.title}
</h3>
<div className="flex items-center gap-4 text-sm text-gray-400">
<span className="flex items-center gap-1">
<Users className="w-4 h-4" />
{marathon.participants_count} участников
{marathons.map((marathon, index) => {
const status = getStatusConfig(marathon.status)
return (
<Link key={marathon.id} to={`/marathons/${marathon.id}`}>
<div
className="group glass rounded-xl p-5 border border-dark-600 transition-all duration-300 hover:border-neon-500/30 hover:-translate-y-0.5 hover:shadow-[0_10px_40px_rgba(0,240,255,0.1)]"
style={{ animationDelay: `${index * 50}ms` }}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{/* Icon */}
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center border border-neon-500/20 group-hover:border-neon-500/40 transition-colors">
<Gamepad2 className="w-7 h-7 text-neon-400" />
</div>
{/* Info */}
<div>
<h3 className="text-lg font-semibold text-white group-hover:text-neon-400 transition-colors mb-1">
{marathon.title}
</h3>
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-400">
<span className="flex items-center gap-1.5">
<Users className="w-4 h-4" />
{marathon.participants_count}
</span>
{marathon.start_date && (
<span className="flex items-center gap-1.5">
<Calendar className="w-4 h-4" />
{format(new Date(marathon.start_date), 'd MMM yyyy', { locale: ru })}
</span>
)}
</div>
</div>
</div>
{/* Status & Arrow */}
<div className="flex items-center gap-4">
<span className={`px-3 py-1.5 rounded-full text-sm font-medium border flex items-center gap-2 ${status.color}`}>
<span className={`w-2 h-2 rounded-full ${status.dot}`} />
{status.text}
</span>
{marathon.start_date && (
<span className="flex items-center gap-1">
<Calendar className="w-4 h-4" />
{format(new Date(marathon.start_date), 'MMM d, yyyy')}
</span>
)}
<ChevronRight className="w-5 h-5 text-gray-600 group-hover:text-neon-400 transition-colors" />
</div>
</div>
<span className={`px-3 py-1 rounded-full text-sm ${getStatusColor(marathon.status)}`}>
{getStatusText(marathon.status)}
</span>
</CardContent>
</Card>
</Link>
))}
</div>
</Link>
)
})}
</div>
)}
</div>

View File

@@ -1,33 +1,62 @@
import { Link } from 'react-router-dom'
import { Button } from '@/components/ui'
import { Gamepad2, Home, Ghost } from 'lucide-react'
import { NeonButton } from '@/components/ui'
import { Gamepad2, Home, Ghost, Sparkles } from 'lucide-react'
export function NotFoundPage() {
return (
<div className="min-h-[60vh] flex flex-col items-center justify-center text-center px-4">
{/* Иконка с анимацией */}
<div className="relative mb-8">
<Ghost className="w-32 h-32 text-gray-700 animate-bounce" />
<Gamepad2 className="w-12 h-12 text-primary-500 absolute -bottom-2 -right-2" />
<div className="min-h-[70vh] flex flex-col items-center justify-center text-center px-4">
{/* Background effects */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/3 -left-32 w-96 h-96 bg-accent-500/5 rounded-full blur-[100px]" />
<div className="absolute bottom-1/3 -right-32 w-96 h-96 bg-neon-500/5 rounded-full blur-[100px]" />
</div>
{/* Заголовок */}
<h1 className="text-7xl font-bold text-white mb-4">404</h1>
<h2 className="text-2xl font-semibold text-gray-400 mb-2">
{/* Icon */}
<div className="relative mb-8 animate-float">
<div className="w-32 h-32 rounded-3xl bg-dark-700/50 border border-dark-600 flex items-center justify-center">
<Ghost className="w-20 h-20 text-gray-600" />
</div>
<div className="absolute -bottom-2 -right-2 w-12 h-12 rounded-xl bg-neon-500/20 border border-neon-500/30 flex items-center justify-center">
<Gamepad2 className="w-6 h-6 text-neon-400" />
</div>
{/* Glitch effect dots */}
<div className="absolute -top-2 -left-2 w-3 h-3 rounded-full bg-accent-500/50 animate-pulse" />
<div className="absolute top-4 -right-4 w-2 h-2 rounded-full bg-neon-500/50 animate-pulse" style={{ animationDelay: '0.5s' }} />
</div>
{/* 404 text with glitch effect */}
<div className="relative mb-4">
<h1 className="text-8xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-neon-400 via-accent-400 to-pink-400">
404
</h1>
<div className="absolute inset-0 text-8xl font-bold text-neon-500/20 blur-xl">
404
</div>
</div>
<h2 className="text-2xl font-bold text-white mb-3">
Страница не найдена
</h2>
<p className="text-gray-500 mb-8 max-w-md">
<p className="text-gray-400 mb-8 max-w-md">
Похоже, эта страница ушла на марафон и не вернулась.
Попробуй начать с главной.
<br />
<span className="text-gray-500">Попробуй начать с главной.</span>
</p>
{/* Кнопка */}
{/* Button */}
<Link to="/">
<Button size="lg" className="flex items-center gap-2">
<Home className="w-5 h-5" />
<NeonButton size="lg" icon={<Home className="w-5 h-5" />}>
На главную
</Button>
</NeonButton>
</Link>
{/* Decorative sparkles */}
<div className="absolute top-1/4 left-1/4 opacity-20">
<Sparkles className="w-6 h-6 text-neon-400 animate-pulse" />
</div>
<div className="absolute bottom-1/3 right-1/4 opacity-20">
<Sparkles className="w-4 h-4 text-accent-400 animate-pulse" style={{ animationDelay: '1s' }} />
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,15 +7,15 @@ import { usersApi, telegramApi, authApi } from '@/api'
import type { UserStats } from '@/types'
import { useToast } from '@/store/toast'
import {
Button, Input, Card, CardHeader, CardTitle, CardContent, clearAvatarCache
NeonButton, Input, GlassCard, StatsCard, clearAvatarCache
} from '@/components/ui'
import {
User, Camera, Trophy, Target, CheckCircle, Flame,
Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
Eye, EyeOff, Save, KeyRound
Eye, EyeOff, Save, KeyRound, Shield
} from 'lucide-react'
// Схемы валидации
// Schemas
const nicknameSchema = z.object({
nickname: z.string().min(2, 'Минимум 2 символа').max(50, 'Максимум 50 символов'),
})
@@ -36,7 +36,7 @@ export function ProfilePage() {
const { user, updateUser } = useAuthStore()
const toast = useToast()
// Состояние
// State
const [stats, setStats] = useState<UserStats | null>(null)
const [isLoadingStats, setIsLoadingStats] = useState(true)
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
@@ -53,7 +53,7 @@ export function ProfilePage() {
const fileInputRef = useRef<HTMLInputElement>(null)
// Формы
// Forms
const nicknameForm = useForm<NicknameForm>({
resolver: zodResolver(nicknameSchema),
defaultValues: { nickname: user?.nickname || '' },
@@ -64,7 +64,7 @@ export function ProfilePage() {
defaultValues: { current_password: '', new_password: '', confirm_password: '' },
})
// Загрузка статистики
// Load stats
useEffect(() => {
loadStats()
return () => {
@@ -72,7 +72,7 @@ export function ProfilePage() {
}
}, [])
// Загрузка аватарки через API
// Load avatar via API
useEffect(() => {
if (user?.id && user?.avatar_url) {
loadAvatar(user.id)
@@ -98,7 +98,7 @@ export function ProfilePage() {
}
}
// Обновляем форму никнейма при изменении user
// Update nickname form when user changes
useEffect(() => {
if (user?.nickname) {
nicknameForm.reset({ nickname: user.nickname })
@@ -116,7 +116,7 @@ export function ProfilePage() {
}
}
// Обновление никнейма
// Update nickname
const onNicknameSubmit = async (data: NicknameForm) => {
try {
const updatedUser = await usersApi.updateNickname(data)
@@ -127,7 +127,7 @@ export function ProfilePage() {
}
}
// Загрузка аватара
// Upload avatar
const handleAvatarClick = () => {
fileInputRef.current?.click()
}
@@ -136,7 +136,6 @@ export function ProfilePage() {
const file = e.target.files?.[0]
if (!file) return
// Валидация
if (!file.type.startsWith('image/')) {
toast.error('Файл должен быть изображением')
return
@@ -150,9 +149,7 @@ export function ProfilePage() {
try {
const updatedUser = await usersApi.uploadAvatar(file)
updateUser({ avatar_url: updatedUser.avatar_url })
// Перезагружаем аватарку через API
if (user?.id) {
// Очищаем старый blob URL и глобальный кэш
if (avatarBlobUrl) {
URL.revokeObjectURL(avatarBlobUrl)
}
@@ -167,7 +164,7 @@ export function ProfilePage() {
}
}
// Смена пароля
// Change password
const onPasswordSubmit = async (data: PasswordForm) => {
try {
await usersApi.changePassword({
@@ -184,7 +181,7 @@ export function ProfilePage() {
}
}
// Telegram функции
// Telegram functions
const startPolling = () => {
setIsPolling(true)
let attempts = 0
@@ -245,265 +242,285 @@ export function ProfilePage() {
}
const isLinked = !!user?.telegram_id
// Приоритет: загруженная аватарка (blob) > телеграм аватарка
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
return (
<div className="max-w-2xl mx-auto space-y-6">
<h1 className="text-2xl font-bold text-white">Мой профиль</h1>
<div className="max-w-3xl mx-auto space-y-6">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Мой профиль</h1>
<p className="text-gray-400">Настройки вашего аккаунта</p>
</div>
{/* Карточка профиля */}
<Card>
<CardContent className="pt-6">
<div className="flex items-start gap-6">
{/* Аватар */}
<div className="relative group flex-shrink-0">
{isLoadingAvatar ? (
<div className="w-24 h-24 rounded-full bg-gray-700 animate-pulse" />
) : (
<button
onClick={handleAvatarClick}
disabled={isUploadingAvatar}
className="relative w-24 h-24 rounded-full overflow-hidden bg-gray-700 hover:opacity-80 transition-opacity"
>
{displayAvatar ? (
<img
src={displayAvatar}
alt={user?.nickname}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<User className="w-12 h-12 text-gray-500" />
</div>
)}
<div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
{isUploadingAvatar ? (
<Loader2 className="w-6 h-6 text-white animate-spin" />
) : (
<Camera className="w-6 h-6 text-white" />
)}
{/* Profile Card */}
<GlassCard variant="neon">
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6">
{/* Avatar */}
<div className="relative group flex-shrink-0">
{isLoadingAvatar ? (
<div className="w-28 h-28 rounded-2xl bg-dark-700 skeleton" />
) : (
<button
onClick={handleAvatarClick}
disabled={isUploadingAvatar}
className="relative w-28 h-28 rounded-2xl overflow-hidden bg-dark-700 border-2 border-neon-500/30 hover:border-neon-500 transition-all group-hover:shadow-[0_0_30px_rgba(0,240,255,0.3)]"
>
{displayAvatar ? (
<img
src={displayAvatar}
alt={user?.nickname}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-neon-500/20 to-accent-500/20">
<User className="w-12 h-12 text-gray-500" />
</div>
</button>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleAvatarChange}
className="hidden"
/>
</div>
{/* Форма никнейма */}
<div className="flex-1">
<form onSubmit={nicknameForm.handleSubmit(onNicknameSubmit)} className="space-y-4">
<Input
label="Никнейм"
{...nicknameForm.register('nickname')}
error={nicknameForm.formState.errors.nickname?.message}
/>
<Button
type="submit"
size="sm"
isLoading={nicknameForm.formState.isSubmitting}
disabled={!nicknameForm.formState.isDirty}
>
<Save className="w-4 h-4 mr-2" />
Сохранить
</Button>
</form>
</div>
</div>
</CardContent>
</Card>
{/* Статистика */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="w-5 h-5 text-yellow-500" />
Статистика
</CardTitle>
</CardHeader>
<CardContent>
{isLoadingStats ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-gray-900 rounded-lg p-4 text-center">
<div className="w-6 h-6 bg-gray-700 rounded mx-auto mb-2 animate-pulse" />
<div className="h-8 w-12 bg-gray-700 rounded mx-auto mb-2 animate-pulse" />
<div className="h-4 w-16 bg-gray-700 rounded mx-auto animate-pulse" />
)}
<div className="absolute inset-0 bg-dark-900/70 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
{isUploadingAvatar ? (
<Loader2 className="w-8 h-8 text-neon-500 animate-spin" />
) : (
<Camera className="w-8 h-8 text-neon-500" />
)}
</div>
))}
</div>
) : stats ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-900 rounded-lg p-4 text-center">
<Target className="w-6 h-6 text-blue-400 mx-auto mb-2" />
<div className="text-2xl font-bold text-white">{stats.marathons_count}</div>
<div className="text-sm text-gray-400">Марафонов</div>
</button>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleAvatarChange}
className="hidden"
/>
</div>
{/* Nickname Form */}
<div className="flex-1 w-full sm:w-auto">
<form onSubmit={nicknameForm.handleSubmit(onNicknameSubmit)} className="space-y-4">
<Input
label="Никнейм"
{...nicknameForm.register('nickname')}
error={nicknameForm.formState.errors.nickname?.message}
/>
<NeonButton
type="submit"
size="sm"
isLoading={nicknameForm.formState.isSubmitting}
disabled={!nicknameForm.formState.isDirty}
icon={<Save className="w-4 h-4" />}
>
Сохранить
</NeonButton>
</form>
</div>
</div>
</GlassCard>
{/* Stats */}
<div>
<h2 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
<Trophy className="w-5 h-5 text-yellow-500" />
Статистика
</h2>
{isLoadingStats ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="glass rounded-xl p-4">
<div className="w-12 h-12 bg-dark-700 rounded-lg mb-3 skeleton" />
<div className="h-8 w-16 bg-dark-700 rounded mb-2 skeleton" />
<div className="h-4 w-20 bg-dark-700 rounded skeleton" />
</div>
<div className="bg-gray-900 rounded-lg p-4 text-center">
<Trophy className="w-6 h-6 text-yellow-500 mx-auto mb-2" />
<div className="text-2xl font-bold text-white">{stats.wins_count}</div>
<div className="text-sm text-gray-400">Побед</div>
</div>
<div className="bg-gray-900 rounded-lg p-4 text-center">
<CheckCircle className="w-6 h-6 text-green-500 mx-auto mb-2" />
<div className="text-2xl font-bold text-white">{stats.completed_assignments}</div>
<div className="text-sm text-gray-400">Заданий</div>
</div>
<div className="bg-gray-900 rounded-lg p-4 text-center">
<Flame className="w-6 h-6 text-orange-500 mx-auto mb-2" />
<div className="text-2xl font-bold text-white">{stats.total_points_earned}</div>
<div className="text-sm text-gray-400">Очков</div>
</div>
</div>
) : (
<p className="text-gray-400 text-center">Не удалось загрузить статистику</p>
)}
</CardContent>
</Card>
))}
</div>
) : stats ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatsCard
label="Марафонов"
value={stats.marathons_count}
icon={<Target className="w-6 h-6" />}
color="neon"
/>
<StatsCard
label="Побед"
value={stats.wins_count}
icon={<Trophy className="w-6 h-6" />}
color="purple"
/>
<StatsCard
label="Заданий"
value={stats.completed_assignments}
icon={<CheckCircle className="w-6 h-6" />}
color="neon"
/>
<StatsCard
label="Очков"
value={stats.total_points_earned}
icon={<Flame className="w-6 h-6" />}
color="pink"
/>
</div>
) : (
<GlassCard className="text-center py-8">
<p className="text-gray-400">Не удалось загрузить статистику</p>
</GlassCard>
)}
</div>
{/* Telegram */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageCircle className="w-5 h-5 text-blue-400" />
Telegram
</CardTitle>
</CardHeader>
<CardContent>
{isLinked ? (
<div className="space-y-4">
<div className="flex items-center gap-4 p-4 bg-gray-900 rounded-lg">
<div className="w-12 h-12 rounded-full bg-blue-500/20 flex items-center justify-center overflow-hidden">
{user?.telegram_avatar_url ? (
<img
src={user.telegram_avatar_url}
alt="Telegram avatar"
className="w-full h-full object-cover"
/>
) : (
<Link2 className="w-6 h-6 text-blue-400" />
)}
</div>
<div className="flex-1">
<p className="text-white font-medium">
{user?.telegram_first_name} {user?.telegram_last_name}
</p>
{user?.telegram_username && (
<p className="text-blue-400 text-sm">@{user.telegram_username}</p>
)}
</div>
<Button
variant="danger"
size="sm"
onClick={handleUnlinkTelegram}
isLoading={telegramLoading}
>
<Link2Off className="w-4 h-4 mr-2" />
Отвязать
</Button>
<GlassCard>
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center">
<MessageCircle className="w-6 h-6 text-blue-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Telegram</h2>
<p className="text-sm text-gray-400">
{isLinked ? 'Аккаунт привязан' : 'Привяжите для уведомлений'}
</p>
</div>
</div>
{isLinked ? (
<div className="space-y-4">
<div className="flex items-center gap-4 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="w-14 h-14 rounded-xl bg-blue-500/20 flex items-center justify-center overflow-hidden border border-blue-500/30">
{user?.telegram_avatar_url ? (
<img
src={user.telegram_avatar_url}
alt="Telegram avatar"
className="w-full h-full object-cover"
/>
) : (
<Link2 className="w-7 h-7 text-blue-400" />
)}
</div>
<div className="flex-1">
<p className="text-white font-medium">
{user?.telegram_first_name} {user?.telegram_last_name}
</p>
{user?.telegram_username && (
<p className="text-blue-400 text-sm">@{user.telegram_username}</p>
)}
</div>
<NeonButton
variant="danger"
size="sm"
onClick={handleUnlinkTelegram}
isLoading={telegramLoading}
icon={<Link2Off className="w-4 h-4" />}
>
Отвязать
</NeonButton>
</div>
) : (
<div className="space-y-4">
<p className="text-gray-400">
Привяжи Telegram для получения уведомлений о событиях и марафонах.
</p>
{isPolling ? (
<div className="p-4 bg-blue-500/20 border border-blue-500/50 rounded-lg">
<div className="flex items-center gap-3">
<Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
<p className="text-blue-400">Ожидание привязки...</p>
</div>
</div>
) : (
<div className="space-y-4">
<p className="text-gray-400">
Привяжите Telegram для получения уведомлений о событиях и марафонах.
</p>
{isPolling ? (
<div className="p-4 bg-blue-500/10 border border-blue-500/30 rounded-xl">
<div className="flex items-center gap-3">
<Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
<p className="text-blue-400">Ожидание привязки...</p>
</div>
) : (
<Button onClick={handleLinkTelegram} isLoading={telegramLoading}>
<ExternalLink className="w-4 h-4 mr-2" />
Привязать Telegram
</Button>
)}
</div>
)}
</CardContent>
</Card>
{/* Смена пароля */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="w-5 h-5 text-gray-400" />
Безопасность
</CardTitle>
</CardHeader>
<CardContent>
{!showPasswordForm ? (
<Button variant="secondary" onClick={() => setShowPasswordForm(true)}>
Сменить пароль
</Button>
) : (
<form onSubmit={passwordForm.handleSubmit(onPasswordSubmit)} className="space-y-4">
<div className="relative">
<Input
label="Текущий пароль"
type={showCurrentPassword ? 'text' : 'password'}
{...passwordForm.register('current_password')}
error={passwordForm.formState.errors.current_password?.message}
/>
<button
type="button"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
className="absolute right-3 top-8 text-gray-400 hover:text-white"
>
{showCurrentPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
) : (
<NeonButton
onClick={handleLinkTelegram}
isLoading={telegramLoading}
icon={<ExternalLink className="w-4 h-4" />}
>
Привязать Telegram
</NeonButton>
)}
</div>
)}
</GlassCard>
<div className="relative">
<Input
label="Новый пароль"
type={showNewPassword ? 'text' : 'password'}
{...passwordForm.register('new_password')}
error={passwordForm.formState.errors.new_password?.message}
/>
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
className="absolute right-3 top-8 text-gray-400 hover:text-white"
>
{showNewPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
{/* Security */}
<GlassCard>
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-xl bg-accent-500/20 flex items-center justify-center">
<Shield className="w-6 h-6 text-accent-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Безопасность</h2>
<p className="text-sm text-gray-400">Управление паролем</p>
</div>
</div>
{!showPasswordForm ? (
<NeonButton
variant="secondary"
onClick={() => setShowPasswordForm(true)}
icon={<KeyRound className="w-4 h-4" />}
>
Сменить пароль
</NeonButton>
) : (
<form onSubmit={passwordForm.handleSubmit(onPasswordSubmit)} className="space-y-4">
<div className="relative">
<Input
label="Подтвердите новый пароль"
type={showNewPassword ? 'text' : 'password'}
{...passwordForm.register('confirm_password')}
error={passwordForm.formState.errors.confirm_password?.message}
label="Текущий пароль"
type={showCurrentPassword ? 'text' : 'password'}
{...passwordForm.register('current_password')}
error={passwordForm.formState.errors.current_password?.message}
/>
<button
type="button"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
className="absolute right-3 top-9 text-gray-400 hover:text-white transition-colors"
>
{showCurrentPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
<div className="flex gap-2">
<Button type="submit" isLoading={passwordForm.formState.isSubmitting}>
Сменить пароль
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
setShowPasswordForm(false)
passwordForm.reset()
}}
>
Отмена
</Button>
</div>
</form>
)}
</CardContent>
</Card>
<div className="relative">
<Input
label="Новый пароль"
type={showNewPassword ? 'text' : 'password'}
{...passwordForm.register('new_password')}
error={passwordForm.formState.errors.new_password?.message}
/>
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
className="absolute right-3 top-9 text-gray-400 hover:text-white transition-colors"
>
{showNewPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
<Input
label="Подтвердите новый пароль"
type={showNewPassword ? 'text' : 'password'}
{...passwordForm.register('confirm_password')}
error={passwordForm.formState.errors.confirm_password?.message}
/>
<div className="flex gap-3">
<NeonButton
type="submit"
isLoading={passwordForm.formState.isSubmitting}
icon={<Save className="w-4 h-4" />}
>
Сменить пароль
</NeonButton>
<NeonButton
type="button"
variant="ghost"
onClick={() => {
setShowPasswordForm(false)
passwordForm.reset()
}}
>
Отмена
</NeonButton>
</div>
</form>
)}
</GlassCard>
</div>
)
}

View File

@@ -5,7 +5,8 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useAuthStore } from '@/store/auth'
import { marathonsApi } from '@/api'
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui'
import { NeonButton, Input } from '@/components/ui'
import { Gamepad2, UserPlus, AlertCircle } from 'lucide-react'
const registerSchema = z.object({
login: z
@@ -68,16 +69,33 @@ export function RegisterPage() {
}
return (
<div className="max-w-md mx-auto">
<Card>
<CardHeader>
<CardTitle className="text-center">Регистрация</CardTitle>
</CardHeader>
<CardContent>
<div className="min-h-[80vh] flex items-center justify-center px-4 -mt-8">
{/* Background effects */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/3 -right-32 w-96 h-96 bg-accent-500/10 rounded-full blur-[100px]" />
<div className="absolute bottom-1/3 -left-32 w-96 h-96 bg-neon-500/10 rounded-full blur-[100px]" />
</div>
<div className="relative w-full max-w-md">
{/* Card */}
<div className="glass-neon rounded-2xl p-8 animate-scale-in">
{/* Header */}
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 rounded-2xl bg-accent-500/10 border border-accent-500/30 flex items-center justify-center">
<Gamepad2 className="w-8 h-8 text-accent-500" />
</div>
</div>
<h1 className="text-2xl font-bold text-white mb-2">Создать аккаунт</h1>
<p className="text-gray-400">Присоединяйтесь к игровому марафону</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{(submitError || error) && (
<div className="p-3 bg-red-500/20 border border-red-500 rounded-lg text-red-500 text-sm">
{submitError || error}
<div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span>{submitError || error}</span>
</div>
)}
@@ -85,12 +103,13 @@ export function RegisterPage() {
label="Логин"
placeholder="Придумайте логин"
error={errors.login?.message}
autoComplete="username"
{...register('login')}
/>
<Input
label="Никнейм"
placeholder="Придумайте никнейм"
placeholder="Как вас называть?"
error={errors.nickname?.message}
{...register('nickname')}
/>
@@ -100,6 +119,7 @@ export function RegisterPage() {
type="password"
placeholder="Придумайте пароль"
error={errors.password?.message}
autoComplete="new-password"
{...register('password')}
/>
@@ -108,22 +128,40 @@ export function RegisterPage() {
type="password"
placeholder="Повторите пароль"
error={errors.confirmPassword?.message}
autoComplete="new-password"
{...register('confirmPassword')}
/>
<Button type="submit" className="w-full" isLoading={isLoading}>
<NeonButton
type="submit"
className="w-full"
size="lg"
color="purple"
isLoading={isLoading}
icon={<UserPlus className="w-5 h-5" />}
>
Зарегистрироваться
</Button>
</NeonButton>
</form>
<p className="text-center text-gray-400 text-sm">
{/* Footer */}
<div className="mt-6 pt-6 border-t border-dark-600 text-center">
<p className="text-gray-400 text-sm">
Уже есть аккаунт?{' '}
<Link to="/login" className="link">
<Link
to="/login"
className="text-accent-400 hover:text-accent-300 transition-colors font-medium"
>
Войти
</Link>
</p>
</form>
</CardContent>
</Card>
</div>
</div>
{/* Decorative elements */}
<div className="absolute -top-4 -left-4 w-24 h-24 border border-accent-500/20 rounded-2xl -z-10" />
<div className="absolute -bottom-4 -right-4 w-32 h-32 border border-neon-500/20 rounded-2xl -z-10" />
</div>
</div>
)
}

View File

@@ -3,10 +3,10 @@ import { useParams, useNavigate, Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { usersApi } from '@/api'
import type { UserProfilePublic } from '@/types'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui'
import { GlassCard, StatsCard } from '@/components/ui'
import {
User, Trophy, Target, CheckCircle, Flame,
Loader2, ArrowLeft, Calendar
Loader2, ArrowLeft, Calendar, Zap
} from 'lucide-react'
export function UserProfilePage() {
@@ -82,8 +82,9 @@ export function UserProfilePage() {
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
<div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка профиля...</p>
</div>
)
}
@@ -91,17 +92,17 @@ export function UserProfilePage() {
if (error || !profile) {
return (
<div className="max-w-2xl mx-auto">
<Card>
<CardContent className="py-12 text-center">
<User className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<h2 className="text-xl font-bold text-white mb-2">
{error || 'Пользователь не найден'}
</h2>
<Link to="/" className="text-primary-400 hover:text-primary-300">
Вернуться на главную
</Link>
</CardContent>
</Card>
<GlassCard className="py-12 text-center">
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-dark-700 flex items-center justify-center">
<User className="w-10 h-10 text-gray-600" />
</div>
<h2 className="text-xl font-bold text-white mb-2">
{error || 'Пользователь не найден'}
</h2>
<Link to="/" className="text-neon-400 hover:text-neon-300 transition-colors">
Вернуться на главную
</Link>
</GlassCard>
</div>
)
}
@@ -111,18 +112,18 @@ export function UserProfilePage() {
{/* Кнопка назад */}
<button
onClick={() => navigate(-1)}
className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
className="flex items-center gap-2 text-gray-400 hover:text-neon-400 transition-colors group"
>
<ArrowLeft className="w-5 h-5" />
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
Назад
</button>
{/* Профиль */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-6">
{/* Аватар */}
<div className="w-24 h-24 rounded-full overflow-hidden bg-gray-700 flex-shrink-0">
<GlassCard variant="neon">
<div className="flex items-center gap-6">
{/* Аватар */}
<div className="relative">
<div className="w-24 h-24 rounded-2xl overflow-hidden bg-dark-700 border-2 border-neon-500/30 shadow-[0_0_20px_rgba(0,240,255,0.2)]">
{avatarBlobUrl ? (
<img
src={avatarBlobUrl}
@@ -130,67 +131,69 @@ export function UserProfilePage() {
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-dark-700 to-dark-800">
<User className="w-12 h-12 text-gray-500" />
</div>
)}
</div>
{/* Инфо */}
<div>
<h1 className="text-2xl font-bold text-white mb-2">
{profile.nickname}
</h1>
<div className="flex items-center gap-2 text-gray-400 text-sm">
<Calendar className="w-4 h-4" />
<span>Зарегистрирован {formatDate(profile.created_at)}</span>
</div>
{/* Online indicator effect */}
<div className="absolute -bottom-1 -right-1 w-6 h-6 rounded-lg bg-neon-500/20 border border-neon-500/30 flex items-center justify-center">
<Zap className="w-3 h-3 text-neon-400" />
</div>
</div>
</CardContent>
</Card>
{/* Инфо */}
<div>
<h1 className="text-2xl font-bold text-white mb-2">
{profile.nickname}
</h1>
<div className="flex items-center gap-2 text-gray-400 text-sm">
<Calendar className="w-4 h-4 text-accent-400" />
<span>Зарегистрирован {formatDate(profile.created_at)}</span>
</div>
</div>
</div>
</GlassCard>
{/* Статистика */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="w-5 h-5 text-yellow-500" />
Статистика
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-900 rounded-lg p-4 text-center">
<Target className="w-6 h-6 text-blue-400 mx-auto mb-2" />
<div className="text-2xl font-bold text-white">
{profile.stats.marathons_count}
</div>
<div className="text-sm text-gray-400">Марафонов</div>
</div>
<div className="bg-gray-900 rounded-lg p-4 text-center">
<Trophy className="w-6 h-6 text-yellow-500 mx-auto mb-2" />
<div className="text-2xl font-bold text-white">
{profile.stats.wins_count}
</div>
<div className="text-sm text-gray-400">Побед</div>
</div>
<div className="bg-gray-900 rounded-lg p-4 text-center">
<CheckCircle className="w-6 h-6 text-green-500 mx-auto mb-2" />
<div className="text-2xl font-bold text-white">
{profile.stats.completed_assignments}
</div>
<div className="text-sm text-gray-400">Заданий</div>
</div>
<div className="bg-gray-900 rounded-lg p-4 text-center">
<Flame className="w-6 h-6 text-orange-500 mx-auto mb-2" />
<div className="text-2xl font-bold text-white">
{profile.stats.total_points_earned}
</div>
<div className="text-sm text-gray-400">Очков</div>
</div>
<GlassCard>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center">
<Trophy className="w-5 h-5 text-yellow-400" />
</div>
</CardContent>
</Card>
<div>
<h2 className="font-semibold text-white">Статистика</h2>
<p className="text-sm text-gray-400">Достижения игрока</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatsCard
label="Марафонов"
value={profile.stats.marathons_count}
icon={<Target className="w-6 h-6" />}
color="neon"
/>
<StatsCard
label="Побед"
value={profile.stats.wins_count}
icon={<Trophy className="w-6 h-6" />}
color="purple"
/>
<StatsCard
label="Заданий"
value={profile.stats.completed_assignments}
icon={<CheckCircle className="w-6 h-6" />}
color="default"
/>
<StatsCard
label="Очков"
value={profile.stats.total_points_earned}
icon={<Flame className="w-6 h-6" />}
color="pink"
/>
</div>
</GlassCard>
</div>
)
}