- {getRankIcon(entry.rank)}
+ {leaderboard.length === 0 ? (
+
+
+
+
+ Пока нет участников
+ Станьте первым в рейтинге!
+
+ ) : (
+ <>
+ {/* Podium for top 3 */}
+ {topThree.length >= 3 && (
+
+
+ {/* 2nd place */}
+
+
+ 2
-
-
-
- {entry.user.nickname}
- {entry.user.id === user?.id && (
- (Вы)
- )}
-
-
- {entry.completed_count} выполнено, {entry.dropped_count} пропущено
-
-
-
- {entry.current_streak > 0 && (
-
-
- {entry.current_streak}
-
- )}
-
-
-
- {entry.total_points}
-
-
очков
+
+
+
{topThree[1].user.nickname}
+
{topThree[1].total_points} очков
- ))}
+
+ {/* 1st place */}
+
+
+
+
+
+
+
{topThree[0].user.nickname}
+
{topThree[0].total_points} очков
+
+
+
+ {/* 3rd place */}
+
+
+ 3
+
+
+
+
{topThree[2].user.nickname}
+
{topThree[2].total_points} очков
+
+
+
)}
-
-
+
+ {/* Stats row */}
+
+
+
+
{leaderboard.length}
+
Участников
+
+
+
+
{totalPoints}
+
Всего очков
+
+
+
+
{maxStreak}
+
Макс. серия
+
+
+
+ {/* Full leaderboard */}
+
+
+
+
+
+
+
Полный рейтинг
+
Все участники марафона
+
+
+
+
+ {leaderboard.map((entry, index) => {
+ const isCurrentUser = entry.user.id === user?.id
+ const rankConfig = getRankConfig(entry.rank)
+
+ return (
+
+ {/* Gradient overlay for top 3 */}
+ {entry.rank <= 3 && (
+
+ )}
+
+ {/* Rank */}
+
+ {rankConfig.icon}
+
+
+ {/* User info */}
+
+
+
+ {entry.user.nickname}
+
+ {isCurrentUser && (
+
+ Вы
+
+ )}
+
+
+
+
+ {entry.completed_count} выполнено
+
+ {entry.dropped_count > 0 && (
+
+
+ {entry.dropped_count} пропущено
+
+ )}
+
+
+
+ {/* Streak */}
+ {entry.current_streak > 0 && (
+
+
+ {entry.current_streak}
+
+ )}
+
+ {/* Points */}
+
+
+ {entry.total_points}
+
+
очков
+
+
+ )
+ })}
+
+
+ >
+ )}
)
}
diff --git a/frontend/src/pages/LobbyPage.tsx b/frontend/src/pages/LobbyPage.tsx
index 9976ef7..35b6204 100644
--- a/frontend/src/pages/LobbyPage.tsx
+++ b/frontend/src/pages/LobbyPage.tsx
@@ -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 (
-
-
+
)
}
@@ -351,21 +344,21 @@ export function LobbyPage() {
switch (status) {
case 'approved':
return (
-
+
Одобрено
)
case 'pending':
return (
-
+
На модерации
)
case 'rejected':
return (
-
+
Отклонено
@@ -376,11 +369,11 @@ export function LobbyPage() {
}
const renderGameCard = (game: Game, showModeration = false) => (
-
+
{/* Game header */}
game.status === 'approved' && handleToggleGameChallenges(game.id)}
>
@@ -394,14 +387,22 @@ export function LobbyPage() {
)}
)}
+
+
+
-
{game.title}
+ {game.title}
{getStatusBadge(game.status)}
{game.genre &&
{game.genre}}
- {game.status === 'approved' &&
{game.challenges_count} заданий}
+ {game.status === 'approved' && (
+
+
+ {game.challenges_count} заданий
+
+ )}
{game.proposed_by && (
@@ -414,49 +415,43 @@ export function LobbyPage() {
e.stopPropagation()}>
{showModeration && game.status === 'pending' && (
<>
-
-
>
)}
{(isOrganizer || game.proposed_by?.id === user?.id) && (
- 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"
>
-
+
)}
{/* Expanded challenges list */}
{expandedGameId === game.id && (
-
+
{loadingChallenges === game.id ? (
-
+
) : (
<>
@@ -464,24 +459,24 @@ export function LobbyPage() {
gameChallenges[game.id].map((challenge) => (
-
-
+
{challenge.difficulty === 'easy' ? 'Легко' :
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
-
+
+{challenge.points}
{challenge.is_generated && (
-
- ИИ
+
+ ИИ
)}
@@ -489,19 +484,17 @@ export function LobbyPage() {
{challenge.description}
{isOrganizer && (
-
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"
>
-
+
)}
))
) : (
-
+
Нет заданий
)}
@@ -509,8 +502,11 @@ export function LobbyPage() {
{/* Add challenge form */}
{isOrganizer && game.status === 'approved' && (
addingChallengeToGameId === game.id ? (
-
-
Новое задание
+
+
+
+ Новое задание
+
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}
/>
@@ -529,7 +525,7 @@ export function LobbyPage() {
-
+
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"
>
@@ -589,44 +585,42 @@ export function LobbyPage() {
setNewChallenge(prev => ({ ...prev, proof_hint: e.target.value }))}
/>
-
handleCreateChallenge(game.id)}
isLoading={isCreatingChallenge}
disabled={!newChallenge.title || !newChallenge.description}
+ icon={}
>
-
Добавить
-
-
+ setAddingChallengeToGameId(null)}
>
Отмена
-
+
) : (
-
{
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"
>
-
+
Добавить задание вручную
-
+
)
)}
>
@@ -639,14 +633,18 @@ export function LobbyPage() {
return (
{/* Back button */}
-
-
+
+
К марафону
-
+ {/* Header */}
+
-
{marathon.title}
+
{marathon.title}
{isOrganizer
? 'Настройка - Добавьте игры и сгенерируйте задания'
@@ -655,296 +653,306 @@ export function LobbyPage() {
{isOrganizer && (
-
-
+ }
+ >
Запустить марафон
-
+
)}
- {/* Stats - только для организаторов */}
+ {/* Stats */}
{isOrganizer && (
-
-
- {approvedGames.length}
-
-
- Игр одобрено
-
-
-
-
-
-
- {totalChallenges}
-
-
- Заданий
-
-
-
+
}
+ color="neon"
+ />
+
}
+ color="purple"
+ />
)}
- {/* Pending games for moderation (organizers only) */}
+ {/* Pending games for moderation */}
{isOrganizer && pendingGames.length > 0 && (
-
-
-
-
- На модерации ({pendingGames.length})
-
-
-
-
- {pendingGames.map((game) => renderGameCard(game, true))}
+
+
+
+
-
-
+
+
На модерации
+
{pendingGames.length} игр ожидают
+
+
+
+ {pendingGames.map((game) => renderGameCard(game, true))}
+
+
)}
- {/* Generate challenges button */}
+ {/* Generate challenges */}
{isOrganizer && approvedGames.length > 0 && !previewChallenges && (
-
-
-
+
+
+
+
+
+
-
Генерация заданий
+
Генерация заданий
- Используйте ИИ для генерации заданий для одобренных игр без заданий
+ ИИ создаст задания для игр без заданий
-
-
- Сгенерировать
-
- {generateMessage && (
-
{generateMessage}
- )}
-
-
+
}
+ >
+ Сгенерировать
+
+
+ {generateMessage && (
+
+ {generateMessage}
+
+ )}
+
)}
- {/* Challenge preview with editing */}
+ {/* Challenge preview */}
{previewChallenges && previewChallenges.length > 0 && (
-
-
-
-
-
Предпросмотр заданий ({previewChallenges.length})
+
+
+
+
+
+
+
+
Предпросмотр заданий
+
{previewChallenges.length} заданий
+
-
-
+ }>
Отмена
-
-
-
+
+ }>
Сохранить все
-
+
-
-
-
- {previewChallenges.map((challenge, index) => (
-
- {editingIndex === index ? (
- // Edit mode
-
-
-
+
+
+ {previewChallenges.map((challenge, index) => (
+
+ {editingIndex === index ? (
+
+
+ {challenge.game_title}
+
+
handleUpdatePreviewChallenge(index, 'title', e.target.value)}
+ placeholder="Название"
+ />
+
+ ) : (
+
+
+
+
{challenge.game_title}
+
+ {challenge.difficulty === 'easy' ? 'Легко' :
+ challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
+
+
+ +{challenge.points} очков
+
-
handleUpdatePreviewChallenge(index, 'title', e.target.value)}
- placeholder="Название"
- className="bg-gray-800"
- />
-
- ) : (
- // View mode
-
-
-
-
- {challenge.game_title}
-
-
- {challenge.difficulty === 'easy' ? 'Легко' :
- challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
-
-
- +{challenge.points} очков
-
-
-
{challenge.title}
-
{challenge.description}
- {challenge.proof_hint && (
-
- Подтверждение: {challenge.proof_hint}
-
- )}
-
-
- setEditingIndex(index)}
- className="text-gray-400 hover:text-white"
- >
-
-
- handleRemovePreviewChallenge(index)}
- className="text-red-400 hover:text-red-300"
- >
-
-
-
+
+ setEditingIndex(index)}
+ className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
+ >
+
+
+ handleRemovePreviewChallenge(index)}
+ className="p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors"
+ >
+
+
- )}
-
- ))}
-
-
-
+
+ )}
+
+ ))}
+
+
)}
{/* Games list */}
-
-
- Игры
- {/* Показываем кнопку если: all_participants ИЛИ (organizer_only И isOrganizer) */}
- {(marathon.game_proposal_mode === 'all_participants' || isOrganizer) && (
- setShowAddGame(!showAddGame)}>
-
- {isOrganizer ? 'Добавить игру' : 'Предложить игру'}
-
- )}
-
-
- {/* Add game form */}
- {showAddGame && (
-
-
setGameTitle(e.target.value)}
- />
-
setGameUrl(e.target.value)}
- />
-
setGameGenre(e.target.value)}
- />
-
-
- {isOrganizer ? 'Добавить' : 'Предложить'}
-
- setShowAddGame(false)}>
- Отмена
-
-
- {!isOrganizer && (
-
- Ваша игра будет отправлена на модерацию организаторам
-
- )}
+
+
+
+ {(marathon.game_proposal_mode === 'all_participants' || isOrganizer) && (
+
setShowAddGame(!showAddGame)}
+ icon={}
+ >
+ {isOrganizer ? 'Добавить' : 'Предложить'}
+
)}
+
- {/* 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 && (
+
+ )}
- return visibleGames.length === 0 ? (
-
+ {/* 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 ? (
+
+
+
+
+
{isOrganizer ? 'Пока нет игр. Добавьте игры, чтобы начать!' : 'Пока нет одобренных игр. Предложите свою!'}
- ) : (
-
- {visibleGames.map((game) => renderGameCard(game, false))}
-
- )
- })()}
-
-
+
+ ) : (
+
+ {visibleGames.map((game) => renderGameCard(game, false))}
+
+ )
+ })()}
+
)
}
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
index c2f0ee5..8b71ce3 100644
--- a/frontend/src/pages/LoginPage.tsx
+++ b/frontend/src/pages/LoginPage.tsx
@@ -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 (
-