Compare commits

...

9 Commits

Author SHA1 Message Date
967176fab8 service 2025-12-17 22:10:01 +07:00
f371178518 500 2025-12-17 21:50:10 +07:00
3920a9bf8c teapot 2025-12-17 21:38:43 +07:00
790b2d6083 ПОЧТИ ГОТОВО 2025-12-17 20:59:47 +07:00
675a0fea0c PIZDEC 2025-12-17 20:29:22 +07:00
0b3837b08e Zaebalsya 2025-12-17 20:19:26 +07:00
7e7cdbcd76 Fix 2025-12-17 19:50:55 +07:00
debdd66458 Fix UI 2025-12-17 18:27:09 +07:00
332491454d Redesign p1 2025-12-17 02:03:33 +07:00
54 changed files with 7575 additions and 2814 deletions

389
REDESIGN_PLAN.md Normal file
View File

@@ -0,0 +1,389 @@
# План редизайна фронтенда Game Marathon
## Концепция дизайна
**Стиль:** Минималистичный геймерский дизайн
- Темная база с неоновыми акцентами (cyan/purple/pink градиенты)
- Glitch эффекты на заголовках и при hover
- Glassmorphism для карточек (blur + transparency)
- Subtle grain/noise текстура на фоне
- Геометрические паттерны и линии
- Микро-анимации везде
**Цветовая палитра:**
```
Background: #0a0a0f (почти черный с синим оттенком)
Surface: #12121a (карточки)
Border: #1e1e2e (границы)
Primary: #00f0ff (cyan неон)
Secondary: #a855f7 (purple)
Accent: #f0abfc (pink)
Success: #22c55e
Error: #ef4444
Text: #e2e8f0
Text Muted: #64748b
```
---
## Фаза 1: Базовая инфраструктура
### 1.1 Обновление Tailwind Config
- [ ] Новая цветовая палитра (neon colors)
- [ ] Кастомные анимации:
- `glitch` - glitch эффект для текста
- `glow-pulse` - пульсация свечения
- `float` - плавное парение
- `slide-in-left/right/up/down` - слайды
- `scale-in` - появление с масштабом
- `shimmer` - блик на элементах
- [ ] Кастомные backdrop-blur классы
- [ ] Градиентные утилиты
### 1.2 Глобальные стили (index.css)
- [ ] CSS переменные для цветов
- [ ] Glitch keyframes анимация
- [ ] Noise/grain overlay
- [ ] Glow эффекты (box-shadow неон)
- [ ] Custom scrollbar (неоновый)
- [ ] Selection стили (выделение текста)
### 1.3 Новые UI компоненты
- [ ] `GlitchText` - текст с glitch эффектом
- [ ] `NeonButton` - кнопка с неоновым свечением
- [ ] `GlassCard` - карточка с glassmorphism
- [ ] `AnimatedCounter` - анимированные числа
- [ ] `ProgressBar` - неоновый прогресс бар
- [ ] `Badge` - бейджи со свечением
- [ ] `Skeleton` - скелетоны загрузки
- [ ] `Tooltip` - тултипы
- [ ] `Tabs` - табы с анимацией
- [ ] `Modal` - переработанная модалка
---
## Фаза 2: Layout и навигация
### 2.1 Новый Header
- [ ] Sticky header с blur при скролле
- [ ] Логотип с glitch эффектом при hover
- [ ] Анимированная навигация (underline slide)
- [ ] Notification bell с badge
- [ ] User dropdown с аватаром
- [ ] Mobile hamburger menu с slide-in
### 2.2 Sidebar (новый компонент)
- [ ] Collapsible sidebar для desktop
- [ ] Иконки с tooltip
- [ ] Active state с неоновой подсветкой
- [ ] Quick stats внизу
### 2.3 Footer
- [ ] Минималистичный footer
- [ ] Social links
- [ ] Version info
---
## Фаза 3: Страницы
### 3.1 HomePage (полный редизайн)
```
┌─────────────────────────────────────────────┐
│ HERO SECTION │
│ ┌─────────────────────────────────────┐ │
│ │ Animated background (particles/ │ │
│ │ geometric shapes) │ │
│ │ │ │
│ │ GAME <glitch>MARATHON</glitch> │ │
│ │ │ │
│ │ Tagline with typing effect │ │
│ │ │ │
│ │ [Start Playing] [Watch Demo] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ FEATURES SECTION (3 glass cards) │
│ ┌───────┐ ┌───────┐ ┌───────┐ │
│ │ Icon │ │ Icon │ │ Icon │ hover: │
│ │ Title │ │ Title │ │ Title │ lift + │
│ │ Desc │ │ Desc │ │ Desc │ glow │
│ └───────┘ └───────┘ └───────┘ │
├─────────────────────────────────────────────┤
│ HOW IT WORKS (timeline style) │
│ ○───────○───────○───────○ │
│ 1 2 3 4 │
│ Create Add Spin Win │
├─────────────────────────────────────────────┤
│ LIVE STATS (animated counters) │
│ [ 1,234 Marathons ] [ 5,678 Challenges ] │
├─────────────────────────────────────────────┤
│ CTA SECTION │
│ Ready to compete? [Join Now] │
└─────────────────────────────────────────────┘
```
### 3.2 Login/Register Pages
- [ ] Centered card с glassmorphism
- [ ] Animated background (subtle)
- [ ] Form inputs с glow при focus
- [ ] Password strength indicator
- [ ] Social login buttons (future)
- [ ] Smooth transitions между login/register
### 3.3 MarathonsPage (Dashboard)
```
┌─────────────────────────────────────────────┐
│ Header: "My Marathons" + Create button │
├─────────────────────────────────────────────┤
│ Quick Stats Bar │
│ [Active: 2] [Completed: 5] [Total Wins: 3]│
├─────────────────────────────────────────────┤
│ Filters/Tabs: All | Active | Completed │
├─────────────────────────────────────────────┤
│ Marathon Cards Grid (2-3 columns) │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Cover image/ │ │ │ │
│ │ gradient │ │ │ │
│ │ ──────────── │ │ │ │
│ │ Title │ │ │ │
│ │ Status badge │ │ │ │
│ │ Participants │ │ │ │
│ │ Progress bar │ │ │ │
│ └──────────────────┘ └──────────────────┘ │
├─────────────────────────────────────────────┤
│ Join by Code (expandable section) │
└─────────────────────────────────────────────┘
```
### 3.4 MarathonPage (Detail)
```
┌─────────────────────────────────────────────┐
│ Hero Banner │
│ ┌─────────────────────────────────────┐ │
│ │ Background gradient + pattern │ │
│ │ Marathon Title (large) │ │
│ │ Status | Dates | Participants │ │
│ │ [Play] [Leaderboard] [Settings] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ Event Banner (if active) - animated │
├────────────────────┬────────────────────────┤
│ Main Content │ Sidebar │
│ ┌──────────────┐ │ ┌──────────────────┐ │
│ │ Your Stats │ │ │ Activity Feed │ │
│ │ Points/Streak│ │ │ (scrollable) │ │
│ └──────────────┘ │ │ │ │
│ ┌──────────────┐ │ │ │ │
│ │ Quick Actions│ │ │ │ │
│ └──────────────┘ │ │ │ │
│ ┌──────────────┐ │ │ │ │
│ │ Games List │ │ │ │ │
│ │ (collapsible)│ │ │ │ │
│ └──────────────┘ │ └──────────────────┘ │
└────────────────────┴────────────────────────┘
```
### 3.5 PlayPage (Game Screen) - ГЛАВНАЯ СТРАНИЦА
```
┌─────────────────────────────────────────────┐
│ Top Bar: Points | Streak | Event Timer │
├─────────────────────────────────────────────┤
│ ┌─────────────────────────────────────┐ │
│ │ SPIN WHEEL │ │
│ │ (redesigned, neon style) │ │
│ │ ┌─────────┐ │ │
│ │ │ WHEEL │ │ │
│ │ │ │ │ │
│ │ └─────────┘ │ │
│ │ [SPIN BUTTON] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ Active Challenge Card (если есть) │
│ ┌─────────────────────────────────────┐ │
│ │ Game: [Title] | Difficulty: [★★★] │ │
│ │ ─────────────────────────────────── │ │
│ │ Challenge Title │ │
│ │ Description... │ │
│ │ ─────────────────────────────────── │ │
│ │ Points: 100 | Time: ~2h │ │
│ │ ─────────────────────────────────── │ │
│ │ Proof Upload Area │ │
│ │ [File] [URL] [Comment] │ │
│ │ ─────────────────────────────────── │ │
│ │ [Complete ✓] [Skip ✗] │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ Mini Leaderboard (top 3 + you) │
└─────────────────────────────────────────────┘
```
### 3.6 LeaderboardPage
- [ ] Animated podium для top 3
- [ ] Table с hover эффектами
- [ ] Highlight для текущего пользователя
- [ ] Streak fire animation
- [ ] Sorting/filtering
### 3.7 ProfilePage
```
┌─────────────────────────────────────────────┐
│ Profile Header │
│ ┌─────────────────────────────────────┐ │
│ │ Avatar (large, glow border) │ │
│ │ Nickname [Edit] │ │
│ │ Member since: Date │ │
│ └─────────────────────────────────────┘ │
├─────────────────────────────────────────────┤
│ Stats Cards (animated counters) │
│ [Marathons] [Wins] [Challenges] [Points] │
├─────────────────────────────────────────────┤
│ Achievements Section (future) │
├─────────────────────────────────────────────┤
│ Telegram Connection Card │
├─────────────────────────────────────────────┤
│ Security Section (password change) │
└─────────────────────────────────────────────┘
```
### 3.8 LobbyPage
- [ ] Step-by-step wizard UI
- [ ] Game cards grid с preview
- [ ] Challenge preview с difficulty badges
- [ ] AI generation progress animation
- [ ] Launch countdown
---
## Фаза 4: Специальные компоненты
### 4.1 SpinWheel (полный редизайн)
- [ ] 3D perspective эффект
- [ ] Неоновые сегменты с названиями игр
- [ ] Particle effects при кручении
- [ ] Glow trail за указателем
- [ ] Sound effects (optional)
- [ ] Confetti при выигрыше
### 4.2 EventBanner (переработка)
- [ ] Pulsating glow border
- [ ] Countdown timer с flip animation
- [ ] Event-specific icons/colors
- [ ] Dismiss animation
### 4.3 ActivityFeed (переработка)
- [ ] Timeline style
- [ ] Avatar circles
- [ ] Type-specific icons
- [ ] Hover для деталей
- [ ] New items animation (slide-in)
### 4.4 Challenge Cards
- [ ] Difficulty stars/badges
- [ ] Progress indicator
- [ ] Expandable details
- [ ] Proof preview thumbnail
---
## Фаза 5: Анимации и эффекты
### 5.1 Page Transitions
- [ ] Framer Motion page transitions
- [ ] Fade + slide between routes
- [ ] Loading skeleton screens
### 5.2 Micro-interactions
- [ ] Button press effects
- [ ] Input focus glow
- [ ] Success checkmark animation
- [ ] Error shake animation
- [ ] Loading spinners (custom)
### 5.3 Background Effects
- [ ] Animated gradient mesh
- [ ] Floating particles (optional)
- [ ] Grid pattern overlay
- [ ] Noise texture
### 5.4 Special Effects
- [ ] Glitch text на заголовках
- [ ] Neon glow на важных элементах
- [ ] Shimmer effect на loading
- [ ] Confetti на achievements
---
## Фаза 6: Responsive и Polish
### 6.1 Mobile Optimization
- [ ] Touch-friendly targets
- [ ] Swipe gestures
- [ ] Bottom navigation (mobile)
- [ ] Collapsible sections
### 6.2 Accessibility
- [ ] Keyboard navigation
- [ ] Focus indicators
- [ ] Screen reader support
- [ ] Reduced motion option
### 6.3 Performance
- [ ] Lazy loading images
- [ ] Code splitting
- [ ] Animation optimization
- [ ] Bundle size check
---
## Порядок реализации
### Sprint 1: Фундамент (2-3 дня)
1. Tailwind config + colors
2. Global CSS + animations
3. Base UI components (Button, Card, Input)
4. GlitchText component
5. Updated Layout/Header
### Sprint 2: Core Pages (3-4 дня)
1. HomePage с hero
2. Login/Register
3. MarathonsPage dashboard
4. Profile page
### Sprint 3: Game Flow (3-4 дня)
1. MarathonPage detail
2. SpinWheel redesign
3. PlayPage
4. LeaderboardPage
### Sprint 4: Polish (2-3 дня)
1. LobbyPage
2. Event components
3. Activity feed
4. Animations & transitions
### Sprint 5: Finalization (1-2 дня)
1. Mobile testing
2. Performance optimization
3. Bug fixes
4. Final polish
---
## Референсы для вдохновления
- Cyberpunk 2077 UI
- Discord dark theme
- Valorant game UI
- Steam profile pages
- Twitch streaming UI
- Epic Games Store
---
## Технические заметки
**Framer Motion:** Использовать для page transitions и сложных анимаций
**CSS:** Использовать для простых transitions и hover эффектов
**Tailwind:** Основной инструмент для стилей
**Custom Hooks:** useAnimation, useGlitch для переиспользования логики

434
auth-pages-backup.tsx Normal file
View File

@@ -0,0 +1,434 @@
// ============================================
// AUTH PAGES BACKUP - Bento Style
// ============================================
// ============================================
// LOGIN PAGE (LoginPage.tsx)
// ============================================
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useAuthStore } from '@/store/auth'
import { marathonsApi } from '@/api'
import { NeonButton, Input, GlassCard } from '@/components/ui'
import { Gamepad2, LogIn, AlertCircle, Trophy, Users, Zap, Target } from 'lucide-react'
const loginSchema = z.object({
login: z.string().min(3, 'Логин должен быть не менее 3 символов'),
password: z.string().min(6, 'Пароль должен быть не менее 6 символов'),
})
type LoginForm = z.infer<typeof loginSchema>
export function LoginPage() {
const navigate = useNavigate()
const { login, isLoading, error, clearError, consumePendingInviteCode } = useAuthStore()
const [submitError, setSubmitError] = useState<string | null>(null)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
})
const onSubmit = async (data: LoginForm) => {
setSubmitError(null)
clearError()
try {
await login(data)
// Check for pending invite code
const pendingCode = consumePendingInviteCode()
if (pendingCode) {
try {
const marathon = await marathonsApi.join(pendingCode)
navigate(`/marathons/${marathon.id}`)
return
} catch {
// If join fails (already member, etc), just go to marathons
}
}
navigate('/marathons')
} catch {
setSubmitError(error || 'Ошибка входа')
}
}
const features = [
{ icon: <Trophy className="w-5 h-5" />, text: 'Соревнуйтесь с друзьями' },
{ icon: <Target className="w-5 h-5" />, text: 'Выполняйте челленджи' },
{ icon: <Zap className="w-5 h-5" />, text: 'Зарабатывайте очки' },
{ icon: <Users className="w-5 h-5" />, text: 'Создавайте марафоны' },
]
return (
<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>
{/* Bento Grid */}
<div className="relative w-full max-w-4xl">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Branding Block (left) */}
<GlassCard className="p-8 flex flex-col justify-center relative overflow-hidden" variant="neon">
{/* Background decoration */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-20 -left-20 w-48 h-48 bg-neon-500/20 rounded-full blur-[60px]" />
<div className="absolute -bottom-20 -right-20 w-48 h-48 bg-accent-500/20 rounded-full blur-[60px]" />
</div>
<div className="relative">
{/* Logo */}
<div className="flex justify-center md:justify-start mb-6">
<div className="w-20 h-20 rounded-2xl bg-neon-500/10 border border-neon-500/30 flex items-center justify-center shadow-[0_0_40px_rgba(0,240,255,0.3)]">
<Gamepad2 className="w-10 h-10 text-neon-500" />
</div>
</div>
{/* Title */}
<h1 className="text-3xl md:text-4xl font-bold text-white mb-2 text-center md:text-left">
Game Marathon
</h1>
<p className="text-gray-400 mb-8 text-center md:text-left">
Платформа для игровых соревнований
</p>
{/* Features */}
<div className="grid grid-cols-2 gap-3">
{features.map((feature, index) => (
<div
key={index}
className="flex items-center gap-2 p-3 rounded-xl bg-dark-700/50 border border-dark-600"
>
<div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center text-neon-400">
{feature.icon}
</div>
<span className="text-sm text-gray-300">{feature.text}</span>
</div>
))}
</div>
</div>
</GlassCard>
{/* Form Block (right) */}
<GlassCard className="p-8">
{/* Header */}
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-white mb-2">Добро пожаловать!</h2>
<p className="text-gray-400">Войдите, чтобы продолжить</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{(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>
)}
<Input
label="Логин"
placeholder="Введите логин"
error={errors.login?.message}
autoComplete="username"
{...register('login')}
/>
<Input
label="Пароль"
type="password"
placeholder="Введите пароль"
error={errors.password?.message}
autoComplete="current-password"
{...register('password')}
/>
<NeonButton
type="submit"
className="w-full"
size="lg"
isLoading={isLoading}
icon={<LogIn className="w-5 h-5" />}
>
Войти
</NeonButton>
</form>
{/* 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="text-neon-400 hover:text-neon-300 transition-colors font-medium"
>
Зарегистрироваться
</Link>
</p>
</div>
</GlassCard>
</div>
{/* Decorative elements */}
<div className="absolute -top-4 -right-4 w-24 h-24 border border-neon-500/20 rounded-2xl -z-10 hidden md:block" />
<div className="absolute -bottom-4 -left-4 w-32 h-32 border border-accent-500/20 rounded-2xl -z-10 hidden md:block" />
</div>
</div>
)
}
// ============================================
// REGISTER PAGE (RegisterPage.tsx)
// ============================================
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useAuthStore } from '@/store/auth'
import { marathonsApi } from '@/api'
import { NeonButton, Input, GlassCard } from '@/components/ui'
import { Gamepad2, UserPlus, AlertCircle, Trophy, Users, Zap, Target, Sparkles } from 'lucide-react'
const registerSchema = z.object({
login: z
.string()
.min(3, 'Логин должен быть не менее 3 символов')
.max(50, 'Логин должен быть не более 50 символов')
.regex(/^[a-zA-Z0-9_]+$/, 'Логин может содержать только буквы, цифры и подчёркивания'),
nickname: z
.string()
.min(2, 'Никнейм должен быть не менее 2 символов')
.max(50, 'Никнейм должен быть не более 50 символов'),
password: z.string().min(6, 'Пароль должен быть не менее 6 символов'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Пароли не совпадают',
path: ['confirmPassword'],
})
type RegisterForm = z.infer<typeof registerSchema>
export function RegisterPage() {
const navigate = useNavigate()
const { register: registerUser, isLoading, error, clearError, consumePendingInviteCode } = useAuthStore()
const [submitError, setSubmitError] = useState<string | null>(null)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterForm>({
resolver: zodResolver(registerSchema),
})
const onSubmit = async (data: RegisterForm) => {
setSubmitError(null)
clearError()
try {
await registerUser({
login: data.login,
password: data.password,
nickname: data.nickname,
})
// Check for pending invite code
const pendingCode = consumePendingInviteCode()
if (pendingCode) {
try {
const marathon = await marathonsApi.join(pendingCode)
navigate(`/marathons/${marathon.id}`)
return
} catch {
// If join fails, just go to marathons
}
}
navigate('/marathons')
} catch {
setSubmitError(error || 'Ошибка регистрации')
}
}
const features = [
{ icon: <Trophy className="w-5 h-5" />, text: 'Соревнуйтесь с друзьями' },
{ icon: <Target className="w-5 h-5" />, text: 'Выполняйте челленджи' },
{ icon: <Zap className="w-5 h-5" />, text: 'Зарабатывайте очки' },
{ icon: <Users className="w-5 h-5" />, text: 'Создавайте марафоны' },
]
return (
<div className="min-h-[80vh] flex items-center justify-center px-4 py-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>
{/* Bento Grid */}
<div className="relative w-full max-w-4xl">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Branding Block (left) */}
<GlassCard className="p-8 flex flex-col justify-center relative overflow-hidden order-2 md:order-1" variant="neon">
{/* Background decoration */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-20 -left-20 w-48 h-48 bg-accent-500/20 rounded-full blur-[60px]" />
<div className="absolute -bottom-20 -right-20 w-48 h-48 bg-neon-500/20 rounded-full blur-[60px]" />
</div>
<div className="relative">
{/* Logo */}
<div className="flex justify-center md:justify-start mb-6">
<div className="w-20 h-20 rounded-2xl bg-accent-500/10 border border-accent-500/30 flex items-center justify-center shadow-[0_0_40px_rgba(147,51,234,0.3)]">
<Gamepad2 className="w-10 h-10 text-accent-500" />
</div>
</div>
{/* Title */}
<h1 className="text-3xl md:text-4xl font-bold text-white mb-2 text-center md:text-left">
Game Marathon
</h1>
<p className="text-gray-400 mb-6 text-center md:text-left">
Присоединяйтесь к игровому сообществу
</p>
{/* Benefits */}
<div className="p-4 rounded-xl bg-dark-700/50 border border-dark-600 mb-6">
<div className="flex items-center gap-2 mb-3">
<Sparkles className="w-5 h-5 text-accent-400" />
<span className="text-white font-semibold">Что вас ждет:</span>
</div>
<ul className="space-y-2 text-sm text-gray-400">
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-neon-500" />
Создавайте игровые марафоны
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-accent-500" />
Выполняйте уникальные челленджи
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-pink-500" />
Соревнуйтесь за первое место
</li>
</ul>
</div>
{/* Features */}
<div className="grid grid-cols-2 gap-3">
{features.map((feature, index) => (
<div
key={index}
className="flex items-center gap-2 p-3 rounded-xl bg-dark-700/50 border border-dark-600"
>
<div className="w-8 h-8 rounded-lg bg-accent-500/20 flex items-center justify-center text-accent-400">
{feature.icon}
</div>
<span className="text-sm text-gray-300">{feature.text}</span>
</div>
))}
</div>
</div>
</GlassCard>
{/* Form Block (right) */}
<GlassCard className="p-8 order-1 md:order-2">
{/* Header */}
<div className="text-center mb-6">
<div className="flex justify-center mb-4 md:hidden">
<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>
<h2 className="text-2xl font-bold text-white mb-2">Создать аккаунт</h2>
<p className="text-gray-400">Начните играть уже сегодня</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{(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>
)}
<Input
label="Логин"
placeholder="Придумайте логин"
error={errors.login?.message}
autoComplete="username"
{...register('login')}
/>
<Input
label="Никнейм"
placeholder="Как вас называть?"
error={errors.nickname?.message}
{...register('nickname')}
/>
<Input
label="Пароль"
type="password"
placeholder="Придумайте пароль"
error={errors.password?.message}
autoComplete="new-password"
{...register('password')}
/>
<Input
label="Подтвердите пароль"
type="password"
placeholder="Повторите пароль"
error={errors.confirmPassword?.message}
autoComplete="new-password"
{...register('confirmPassword')}
/>
<NeonButton
type="submit"
className="w-full"
size="lg"
color="purple"
isLoading={isLoading}
icon={<UserPlus className="w-5 h-5" />}
>
Зарегистрироваться
</NeonButton>
</form>
{/* 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="text-accent-400 hover:text-accent-300 transition-colors font-medium"
>
Войти
</Link>
</p>
</div>
</GlassCard>
</div>
{/* Decorative elements */}
<div className="absolute -top-4 -left-4 w-24 h-24 border border-accent-500/20 rounded-2xl -z-10 hidden md:block" />
<div className="absolute -bottom-4 -right-4 w-32 h-32 border border-neon-500/20 rounded-2xl -z-10 hidden md:block" />
</div>
</div>
)
}

View File

@@ -13,6 +13,7 @@ from app.schemas import (
ChallengePreview, ChallengePreview,
ChallengesPreviewResponse, ChallengesPreviewResponse,
ChallengesSaveRequest, ChallengesSaveRequest,
ChallengesGenerateRequest,
) )
from app.services.gpt import gpt_service from app.services.gpt import gpt_service
@@ -187,7 +188,12 @@ async def create_challenge(
@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse) @router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse)
async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession): async def preview_challenges(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
data: ChallengesGenerateRequest | None = None,
):
"""Generate challenges preview for approved games in marathon using GPT (without saving). Organizers only.""" """Generate challenges preview for approved games in marathon using GPT (without saving). Organizers only."""
# Check marathon # Check marathon
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id)) result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
@@ -202,21 +208,35 @@ async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: Db
await require_organizer(db, current_user, marathon_id) await require_organizer(db, current_user, marathon_id)
# Get only APPROVED games # Get only APPROVED games
result = await db.execute( query = select(Game).where(
select(Game).where(
Game.marathon_id == marathon_id, Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value, Game.status == GameStatus.APPROVED.value,
) )
)
# Filter by specific game IDs if provided
if data and data.game_ids:
query = query.where(Game.id.in_(data.game_ids))
result = await db.execute(query)
games = result.scalars().all() games = result.scalars().all()
if not games: if not games:
raise HTTPException(status_code=400, detail="No approved games in marathon") raise HTTPException(status_code=400, detail="No approved games found")
# Filter games that don't have challenges yet # Build games list for generation (skip games that already have challenges, unless specific IDs requested)
games_to_generate = [] games_to_generate = []
game_map = {} game_map = {}
for game in games: for game in games:
# If specific games requested, generate even if they have challenges
if data and data.game_ids:
games_to_generate.append({
"id": game.id,
"title": game.title,
"genre": game.genre
})
game_map[game.id] = game.title
else:
# Otherwise only generate for games without challenges
existing = await db.scalar( existing = await db.scalar(
select(Challenge.id).where(Challenge.game_id == game.id).limit(1) select(Challenge.id).where(Challenge.game_id == game.id).limit(1)
) )

View File

@@ -10,6 +10,7 @@ from app.core.config import settings
from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
from app.services.storage import storage_service from app.services.storage import storage_service
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(tags=["games"]) router = APIRouter(tags=["games"])
@@ -268,6 +269,13 @@ async def approve_game(game_id: int, current_user: CurrentUser, db: DbSession):
if game.status != GameStatus.PENDING.value: if game.status != GameStatus.PENDING.value:
raise HTTPException(status_code=400, detail="Game is not pending") raise HTTPException(status_code=400, detail="Game is not pending")
# Get marathon title for notification
marathon_result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
marathon = marathon_result.scalar_one()
# Save proposer id before status change
proposer_id = game.proposed_by_id
game.status = GameStatus.APPROVED.value game.status = GameStatus.APPROVED.value
game.approved_by_id = current_user.id game.approved_by_id = current_user.id
@@ -283,6 +291,12 @@ async def approve_game(game_id: int, current_user: CurrentUser, db: DbSession):
await db.commit() await db.commit()
await db.refresh(game) await db.refresh(game)
# Notify proposer (if not self-approving)
if proposer_id and proposer_id != current_user.id:
await telegram_notifier.notify_game_approved(
db, proposer_id, marathon.title, game.title
)
# Need to reload relationships # Need to reload relationships
game = await get_game_or_404(db, game_id) game = await get_game_or_404(db, game_id)
challenges_count = await db.scalar( challenges_count = await db.scalar(
@@ -302,6 +316,14 @@ async def reject_game(game_id: int, current_user: CurrentUser, db: DbSession):
if game.status != GameStatus.PENDING.value: if game.status != GameStatus.PENDING.value:
raise HTTPException(status_code=400, detail="Game is not pending") raise HTTPException(status_code=400, detail="Game is not pending")
# Get marathon title for notification
marathon_result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
marathon = marathon_result.scalar_one()
# Save proposer id and game title before changes
proposer_id = game.proposed_by_id
game_title = game.title
game.status = GameStatus.REJECTED.value game.status = GameStatus.REJECTED.value
# Log activity # Log activity
@@ -316,6 +338,12 @@ async def reject_game(game_id: int, current_user: CurrentUser, db: DbSession):
await db.commit() await db.commit()
await db.refresh(game) await db.refresh(game)
# Notify proposer
if proposer_id and proposer_id != current_user.id:
await telegram_notifier.notify_game_rejected(
db, proposer_id, marathon.title, game_title
)
# Need to reload relationships # Need to reload relationships
game = await get_game_or_404(db, game_id) game = await get_game_or_404(db, game_id)
challenges_count = await db.scalar( challenges_count = await db.scalar(

View File

@@ -1,5 +1,6 @@
from datetime import timedelta from datetime import timedelta
import secrets import secrets
import string
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -10,7 +11,7 @@ from app.api.deps import (
get_participant, get_participant,
) )
from app.models import ( from app.models import (
Marathon, Participant, MarathonStatus, Game, GameStatus, Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole, Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
) )
from app.schemas import ( from app.schemas import (
@@ -40,7 +41,7 @@ async def get_marathon_by_code(invite_code: str, db: DbSession):
select(Marathon, func.count(Participant.id).label("participants_count")) select(Marathon, func.count(Participant.id).label("participants_count"))
.outerjoin(Participant) .outerjoin(Participant)
.options(selectinload(Marathon.creator)) .options(selectinload(Marathon.creator))
.where(Marathon.invite_code == invite_code) .where(func.upper(Marathon.invite_code) == invite_code.upper())
.group_by(Marathon.id) .group_by(Marathon.id)
) )
row = result.first() row = result.first()
@@ -62,7 +63,9 @@ async def get_marathon_by_code(invite_code: str, db: DbSession):
def generate_invite_code() -> str: def generate_invite_code() -> str:
return secrets.token_urlsafe(8) """Generate a clean 8-character uppercase alphanumeric code."""
alphabet = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(8))
async def get_marathon_or_404(db, marathon_id: int) -> Marathon: async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
@@ -272,15 +275,33 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
if marathon.status != MarathonStatus.PREPARING.value: if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Marathon is not in preparing state") raise HTTPException(status_code=400, detail="Marathon is not in preparing state")
# Check if there are approved games with challenges # Check if there are approved games
games_count = await db.scalar( games_result = await db.execute(
select(func.count()).select_from(Game).where( select(Game).where(
Game.marathon_id == marathon_id, Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value, Game.status == GameStatus.APPROVED.value,
) )
) )
if games_count == 0: approved_games = games_result.scalars().all()
raise HTTPException(status_code=400, detail="Add and approve at least one game before starting")
if len(approved_games) == 0:
raise HTTPException(status_code=400, detail="Добавьте и одобрите хотя бы одну игру")
# Check that all approved games have at least one challenge
games_without_challenges = []
for game in approved_games:
challenge_count = await db.scalar(
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
)
if challenge_count == 0:
games_without_challenges.append(game.title)
if games_without_challenges:
games_list = ", ".join(games_without_challenges)
raise HTTPException(
status_code=400,
detail=f"У следующих игр нет челленджей: {games_list}"
)
marathon.status = MarathonStatus.ACTIVE.value marathon.status = MarathonStatus.ACTIVE.value
@@ -332,7 +353,7 @@ async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSes
@router.post("/join", response_model=MarathonResponse) @router.post("/join", response_model=MarathonResponse)
async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSession): async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSession):
result = await db.execute( result = await db.execute(
select(Marathon).where(Marathon.invite_code == data.invite_code) select(Marathon).where(func.upper(Marathon.invite_code) == data.invite_code.upper())
) )
marathon = result.scalar_one_or_none() marathon = result.scalar_one_or_none()

View File

@@ -37,6 +37,7 @@ from app.schemas.challenge import (
ChallengesPreviewResponse, ChallengesPreviewResponse,
ChallengeSaveItem, ChallengeSaveItem,
ChallengesSaveRequest, ChallengesSaveRequest,
ChallengesGenerateRequest,
) )
from app.schemas.assignment import ( from app.schemas.assignment import (
CompleteAssignment, CompleteAssignment,
@@ -118,6 +119,7 @@ __all__ = [
"ChallengesPreviewResponse", "ChallengesPreviewResponse",
"ChallengeSaveItem", "ChallengeSaveItem",
"ChallengesSaveRequest", "ChallengesSaveRequest",
"ChallengesGenerateRequest",
# Assignment # Assignment
"CompleteAssignment", "CompleteAssignment",
"AssignmentResponse", "AssignmentResponse",

View File

@@ -88,3 +88,8 @@ class ChallengeSaveItem(BaseModel):
class ChallengesSaveRequest(BaseModel): class ChallengesSaveRequest(BaseModel):
"""Request to save previewed challenges""" """Request to save previewed challenges"""
challenges: list[ChallengeSaveItem] challenges: list[ChallengeSaveItem]
class ChallengesGenerateRequest(BaseModel):
"""Request to generate challenges for specific games"""
game_ids: list[int] | None = None # If None, generate for all approved games without challenges

View File

@@ -244,6 +244,38 @@ class TelegramNotifier:
) )
return await self.notify_user(db, user_id, message) return await self.notify_user(db, user_id, message)
async def notify_game_approved(
self,
db: AsyncSession,
user_id: int,
marathon_title: str,
game_title: str
) -> bool:
"""Notify user that their proposed game was approved."""
message = (
f"✅ <b>Твоя игра одобрена!</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Игра: {game_title}\n\n"
f"Теперь она доступна для всех участников."
)
return await self.notify_user(db, user_id, message)
async def notify_game_rejected(
self,
db: AsyncSession,
user_id: int,
marathon_title: str,
game_title: str
) -> bool:
"""Notify user that their proposed game was rejected."""
message = (
f"❌ <b>Твоя игра отклонена</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Игра: {game_title}\n\n"
f"Ты можешь предложить другую игру."
)
return await self.notify_user(db, user_id, message)
# Global instance # Global instance
telegram_notifier = TelegramNotifier() telegram_notifier = TelegramNotifier()

View File

@@ -5,6 +5,7 @@ import sys
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode from aiogram.enums import ParseMode
from aiohttp import web
from config import settings from config import settings
from handlers import start, marathons, link from handlers import start, marathons, link
@@ -23,14 +24,41 @@ logger = logging.getLogger(__name__)
# Set aiogram logging level # Set aiogram logging level
logging.getLogger("aiogram").setLevel(logging.INFO) logging.getLogger("aiogram").setLevel(logging.INFO)
# Health check state
bot_running = False
async def health_handler(request):
"""Health check endpoint"""
if bot_running:
return web.json_response({"status": "ok", "service": "telegram-bot"})
return web.json_response({"status": "starting"}, status=503)
async def start_health_server():
"""Start health check HTTP server"""
app = web.Application()
app.router.add_get("/health", health_handler)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", 8080)
await site.start()
logger.info("Health check server started on port 8080")
return runner
async def main(): async def main():
global bot_running
logger.info("="*50) logger.info("="*50)
logger.info("Starting Game Marathon Bot...") logger.info("Starting Game Marathon Bot...")
logger.info(f"API_URL: {settings.API_URL}") logger.info(f"API_URL: {settings.API_URL}")
logger.info(f"BOT_TOKEN: {settings.TELEGRAM_BOT_TOKEN[:20]}...") logger.info(f"BOT_TOKEN: {settings.TELEGRAM_BOT_TOKEN[:20]}...")
logger.info("="*50) logger.info("="*50)
# Start health check server
health_runner = await start_health_server()
bot = Bot( bot = Bot(
token=settings.TELEGRAM_BOT_TOKEN, token=settings.TELEGRAM_BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML) default=DefaultBotProperties(parse_mode=ParseMode.HTML)
@@ -54,11 +82,18 @@ async def main():
dp.include_router(marathons.router) dp.include_router(marathons.router)
logger.info("Routers registered: start, link, marathons") logger.info("Routers registered: start, link, marathons")
# Mark bot as running
bot_running = True
# Start polling # Start polling
logger.info("Deleting webhook and starting polling...") logger.info("Deleting webhook and starting polling...")
await bot.delete_webhook(drop_pending_updates=True) await bot.delete_webhook(drop_pending_updates=True)
logger.info("Polling started! Waiting for messages...") logger.info("Polling started! Waiting for messages...")
try:
await dp.start_polling(bot) await dp.start_polling(bot)
finally:
bot_running = False
await health_runner.cleanup()
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -85,5 +85,23 @@ services:
- backend - backend
restart: unless-stopped restart: unless-stopped
status:
build:
context: ./status-service
dockerfile: Dockerfile
container_name: marathon-status
environment:
BACKEND_URL: http://backend:8000
FRONTEND_URL: http://frontend:80
BOT_URL: http://bot:8080
CHECK_INTERVAL: "30"
ports:
- "8001:8001"
depends_on:
- backend
- frontend
- bot
restart: unless-stopped
volumes: volumes:
postgres_data: postgres_data:

View File

@@ -20,6 +20,8 @@ import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage'
import { ProfilePage } from '@/pages/ProfilePage' import { ProfilePage } from '@/pages/ProfilePage'
import { UserProfilePage } from '@/pages/UserProfilePage' import { UserProfilePage } from '@/pages/UserProfilePage'
import { NotFoundPage } from '@/pages/NotFoundPage' import { NotFoundPage } from '@/pages/NotFoundPage'
import { TeapotPage } from '@/pages/TeapotPage'
import { ServerErrorPage } from '@/pages/ServerErrorPage'
// Protected route wrapper // Protected route wrapper
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -148,6 +150,15 @@ function App() {
<Route path="users/:id" element={<UserProfilePage />} /> <Route path="users/:id" element={<UserProfilePage />} />
{/* Easter egg - 418 I'm a teapot */}
<Route path="418" element={<TeapotPage />} />
<Route path="teapot" element={<TeapotPage />} />
<Route path="tea" element={<TeapotPage />} />
{/* Server error page */}
<Route path="500" element={<ServerErrorPage />} />
<Route path="error" element={<ServerErrorPage />} />
{/* 404 - must be last */} {/* 404 - must be last */}
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Route> </Route>

View File

@@ -22,11 +22,28 @@ client.interceptors.request.use((config) => {
client.interceptors.response.use( client.interceptors.response.use(
(response) => response, (response) => response,
(error: AxiosError<{ detail: string }>) => { (error: AxiosError<{ detail: string }>) => {
// Unauthorized - redirect to login
if (error.response?.status === 401) { if (error.response?.status === 401) {
localStorage.removeItem('token') localStorage.removeItem('token')
localStorage.removeItem('user') localStorage.removeItem('user')
window.location.href = '/login' window.location.href = '/login'
} }
// Server error or network error - redirect to 500 page
if (
error.response?.status === 500 ||
error.response?.status === 502 ||
error.response?.status === 503 ||
error.response?.status === 504 ||
error.code === 'ERR_NETWORK' ||
error.code === 'ECONNABORTED'
) {
// Only redirect if not already on error page
if (!window.location.pathname.startsWith('/500') && !window.location.pathname.startsWith('/error')) {
window.location.href = '/500'
}
}
return Promise.reject(error) return Promise.reject(error)
} }
) )

View File

@@ -79,8 +79,9 @@ export const gamesApi = {
await client.delete(`/challenges/${id}`) await client.delete(`/challenges/${id}`)
}, },
previewChallenges: async (marathonId: number): Promise<ChallengesPreviewResponse> => { previewChallenges: async (marathonId: number, gameIds?: number[]): Promise<ChallengesPreviewResponse> => {
const response = await client.post<ChallengesPreviewResponse>(`/marathons/${marathonId}/preview-challenges`) const data = gameIds?.length ? { game_ids: gameIds } : undefined
const response = await client.post<ChallengesPreviewResponse>(`/marathons/${marathonId}/preview-challenges`, data)
return response.data return response.data
}, },

View File

@@ -41,8 +41,9 @@ export const usersApi = {
}, },
// Получить аватар пользователя как blob URL // Получить аватар пользователя как blob URL
getAvatarUrl: async (userId: number): Promise<string> => { getAvatarUrl: async (userId: number, bustCache = false): Promise<string> => {
const response = await client.get(`/users/${userId}/avatar`, { const cacheBuster = bustCache ? `?t=${Date.now()}` : ''
const response = await client.get(`/users/${userId}/avatar${cacheBuster}`, {
responseType: 'blob', responseType: 'blob',
}) })
return URL.createObjectURL(response.data) return URL.createObjectURL(response.data)

View File

@@ -1,14 +1,13 @@
import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react' import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate, Link } from 'react-router-dom'
import { feedApi } from '@/api' import { feedApi } from '@/api'
import type { Activity, ActivityType } from '@/types' import type { Activity, ActivityType } from '@/types'
import { Loader2, ChevronDown, Bell, ExternalLink, AlertTriangle } from 'lucide-react' import { Loader2, ChevronDown, Activity as ActivityIcon, ExternalLink, AlertTriangle, Sparkles, Zap } from 'lucide-react'
import { UserAvatar } from '@/components/ui' import { UserAvatar } from '@/components/ui'
import { import {
formatRelativeTime, formatRelativeTime,
getActivityIcon, getActivityIcon,
getActivityColor, getActivityColor,
getActivityBgClass,
isEventActivity, isEventActivity,
formatActivityMessage, formatActivityMessage,
} from '@/utils/activity' } from '@/utils/activity'
@@ -100,52 +99,66 @@ export const ActivityFeed = forwardRef<ActivityFeedRef, ActivityFeedProps>(
if (isLoading) { if (isLoading) {
return ( return (
<div className={`bg-gray-800/50 rounded-xl border border-gray-700/50 p-4 flex flex-col ${className}`}> <div className={`glass rounded-2xl border border-dark-600 flex flex-col ${className}`}>
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-3 px-5 py-4 border-b border-dark-600">
<Bell className="w-5 h-5 text-primary-500" /> <div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center">
<h3 className="font-medium text-white">Активность</h3> <ActivityIcon className="w-4 h-4 text-neon-400" />
</div> </div>
<div className="flex-1 flex items-center justify-center"> <h3 className="font-semibold text-white">Активность</h3>
<Loader2 className="w-6 h-6 animate-spin text-gray-500" /> </div>
<div className="flex-1 flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-neon-500" />
</div> </div>
</div> </div>
) )
} }
return ( return (
<div className={`bg-gray-800/50 rounded-xl border border-gray-700/50 flex flex-col ${className}`}> <div className={`glass rounded-2xl border border-dark-600 flex flex-col overflow-hidden ${className}`}>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700/50 flex-shrink-0"> <div className="flex items-center justify-between px-5 py-4 border-b border-dark-600 flex-shrink-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<Bell className="w-5 h-5 text-primary-500" /> <div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center">
<h3 className="font-medium text-white">Активность</h3> <Zap className="w-4 h-4 text-neon-400" />
</div> </div>
<div>
<h3 className="font-semibold text-white">Активность</h3>
{total > 0 && ( {total > 0 && (
<span className="text-xs text-gray-500">{total}</span> <p className="text-xs text-gray-500">{total} событий</p>
)} )}
</div> </div>
</div>
<div className="w-2 h-2 rounded-full bg-neon-500 animate-pulse" />
</div>
{/* Activity list */} {/* Activity list */}
<div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar"> <div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar">
{activities.length === 0 ? ( {activities.length === 0 ? (
<div className="px-4 py-8 text-center text-gray-500 text-sm"> <div className="px-5 py-12 text-center">
Пока нет активности <div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-dark-700 flex items-center justify-center">
<Sparkles className="w-6 h-6 text-gray-600" />
</div>
<p className="text-gray-400 text-sm">Пока нет активности</p>
</div> </div>
) : ( ) : (
<div className="divide-y divide-gray-700/30"> <div className="divide-y divide-dark-600/50">
{activities.map((activity) => ( {activities.map((activity, index) => (
<ActivityItem key={activity.id} activity={activity} /> <ActivityItem
key={activity.id}
activity={activity}
isNew={index === 0}
/>
))} ))}
</div> </div>
)} )}
{/* Load more button */} {/* Load more button */}
{hasMore && ( {hasMore && (
<div className="p-3 border-t border-gray-700/30"> <div className="p-4 border-t border-dark-600/50">
<button <button
onClick={handleLoadMore} onClick={handleLoadMore}
disabled={isLoadingMore} disabled={isLoadingMore}
className="w-full py-2 text-sm text-gray-400 hover:text-white transition-colors flex items-center justify-center gap-2" className="w-full py-2.5 text-sm text-gray-400 hover:text-neon-400 transition-colors flex items-center justify-center gap-2 rounded-lg hover:bg-neon-500/5"
> >
{isLoadingMore ? ( {isLoadingMore ? (
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
@@ -168,13 +181,13 @@ ActivityFeed.displayName = 'ActivityFeed'
interface ActivityItemProps { interface ActivityItemProps {
activity: Activity activity: Activity
isNew?: boolean
} }
function ActivityItem({ activity }: ActivityItemProps) { function ActivityItem({ activity, isNew }: ActivityItemProps) {
const navigate = useNavigate() const navigate = useNavigate()
const Icon = getActivityIcon(activity.type) const Icon = getActivityIcon(activity.type)
const iconColor = getActivityColor(activity.type) const iconColor = getActivityColor(activity.type)
const bgClass = getActivityBgClass(activity.type)
const isEvent = isEventActivity(activity.type) const isEvent = isEventActivity(activity.type)
const { title, details, extra } = formatActivityMessage(activity) const { title, details, extra } = formatActivityMessage(activity)
@@ -187,21 +200,58 @@ function ActivityItem({ activity }: ActivityItemProps) {
? activityData.dispute_status ? activityData.dispute_status
: null : null
// Determine accent color based on activity type
const getAccentConfig = () => {
switch (activity.type) {
case 'spin':
return { border: 'border-l-accent-500', bg: 'bg-accent-500/5' }
case 'complete':
return { border: 'border-l-green-500', bg: 'bg-green-500/5' }
case 'drop':
return { border: 'border-l-red-500', bg: 'bg-red-500/5' }
case 'start_marathon':
case 'event_start':
return { border: 'border-l-yellow-500', bg: 'bg-yellow-500/5' }
case 'finish_marathon':
case 'event_end':
return { border: 'border-l-gray-500', bg: 'bg-gray-500/5' }
case 'swap':
case 'rematch':
return { border: 'border-l-neon-500', bg: 'bg-neon-500/5' }
default:
return { border: 'border-l-dark-600', bg: '' }
}
}
const accent = getAccentConfig()
if (isEvent) { if (isEvent) {
return ( return (
<div className={`px-4 py-3 ${bgClass} border-l-2 ${activity.type === 'event_start' ? 'border-l-yellow-500' : 'border-l-gray-600'}`}> <div className={`
<div className="flex items-center gap-2 mb-1"> px-5 py-4 border-l-2 transition-colors
<Icon className={`w-4 h-4 ${iconColor}`} /> ${accent.border} ${accent.bg}
<span className={`text-sm font-medium ${activity.type === 'event_start' ? 'text-yellow-400' : 'text-gray-400'}`}> hover:bg-dark-700/30
`}>
<div className="flex items-center gap-2 mb-1.5">
<div className={`w-6 h-6 rounded-md flex items-center justify-center ${
activity.type === 'event_start' ? 'bg-yellow-500/20' : 'bg-gray-500/20'
}`}>
<Icon className={`w-3.5 h-3.5 ${iconColor}`} />
</div>
<span className={`text-sm font-semibold ${
activity.type === 'event_start' ? 'text-yellow-400' : 'text-gray-400'
}`}>
{title} {title}
</span> </span>
</div> </div>
{details && ( {details && (
<div className={`text-sm ${activity.type === 'event_start' ? 'text-yellow-200' : 'text-gray-500'}`}> <div className={`text-sm ml-8 ${
activity.type === 'event_start' ? 'text-yellow-200/80' : 'text-gray-500'
}`}>
{details} {details}
</div> </div>
)} )}
<div className="text-xs text-gray-500 mt-1"> <div className="text-xs text-gray-600 mt-2 ml-8">
{formatRelativeTime(activity.created_at)} {formatRelativeTime(activity.created_at)}
</div> </div>
</div> </div>
@@ -209,39 +259,57 @@ function ActivityItem({ activity }: ActivityItemProps) {
} }
return ( return (
<div className={`px-4 py-3 hover:bg-gray-700/20 transition-colors ${bgClass}`}> <div className={`
px-5 py-4 border-l-2 transition-all duration-200
${accent.border} ${isNew ? accent.bg : ''}
hover:bg-dark-700/30 group
`}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{/* Avatar */} {/* Avatar */}
<div className="flex-shrink-0"> <Link to={`/users/${activity.user.id}`} className="flex-shrink-0 relative" onClick={(e) => e.stopPropagation()}>
<UserAvatar <UserAvatar
userId={activity.user.id} userId={activity.user.id}
hasAvatar={!!activity.user.avatar_url} hasAvatar={!!activity.user.avatar_url}
nickname={activity.user.nickname} nickname={activity.user.nickname}
size="sm" size="sm"
/> />
{/* Activity type badge */}
<div className={`
absolute -bottom-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center
border-2 border-dark-800
${activity.type === 'complete' ? 'bg-green-500' :
activity.type === 'drop' ? 'bg-red-500' :
activity.type === 'spin' ? 'bg-accent-500' :
'bg-neon-500'}
`}>
<Icon className="w-2.5 h-2.5 text-white" />
</div> </div>
</Link>
{/* Content */} {/* Content */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-white truncate"> <Link
to={`/users/${activity.user.id}`}
className="text-sm font-semibold text-white hover:text-neon-400 transition-colors"
onClick={(e) => e.stopPropagation()}
>
{activity.user.nickname} {activity.user.nickname}
</span> </Link>
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-600">
{formatRelativeTime(activity.created_at)} {formatRelativeTime(activity.created_at)}
</span> </span>
</div> </div>
<div className="flex items-center gap-1.5 mt-0.5"> <div className="flex items-center gap-1.5 mt-1">
<Icon className={`w-3.5 h-3.5 flex-shrink-0 ${iconColor}`} />
<span className="text-sm text-gray-300">{title}</span> <span className="text-sm text-gray-300">{title}</span>
</div> </div>
{details && ( {details && (
<div className="text-sm text-gray-400 mt-1"> <div className="text-sm text-gray-500 mt-1">
{details} {details}
</div> </div>
)} )}
{extra && ( {extra && (
<div className="text-xs text-gray-500 mt-0.5"> <div className="text-xs text-gray-600 mt-1">
{extra} {extra}
</div> </div>
)} )}
@@ -250,19 +318,19 @@ function ActivityItem({ activity }: ActivityItemProps) {
<div className="flex items-center gap-3 mt-2"> <div className="flex items-center gap-3 mt-2">
<button <button
onClick={() => navigate(`/assignments/${assignmentId}`)} onClick={() => navigate(`/assignments/${assignmentId}`)}
className="text-xs text-primary-400 hover:text-primary-300 flex items-center gap-1" className="text-xs text-neon-400 hover:text-neon-300 flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-neon-500/10 transition-colors"
> >
<ExternalLink className="w-3 h-3" /> <ExternalLink className="w-3 h-3" />
Детали Детали
</button> </button>
{disputeStatus === 'open' && ( {disputeStatus === 'open' && (
<span className="text-xs text-orange-400 flex items-center gap-1"> <span className="text-xs text-orange-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-orange-500/10">
<AlertTriangle className="w-3 h-3" /> <AlertTriangle className="w-3 h-3" />
Оспаривается Оспаривается
</span> </span>
)} )}
{disputeStatus === 'valid' && ( {disputeStatus === 'valid' && (
<span className="text-xs text-red-400 flex items-center gap-1"> <span className="text-xs text-red-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-red-500/10">
<AlertTriangle className="w-3 h-3" /> <AlertTriangle className="w-3 h-3" />
Отклонено Отклонено
</span> </span>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Clock } from 'lucide-react' import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Clock, Sparkles } from 'lucide-react'
import type { ActiveEvent, EventType } from '@/types' import type { ActiveEvent, EventType } from '@/types'
import { EVENT_INFO } from '@/types' import { EVENT_INFO } from '@/types'
@@ -17,13 +17,55 @@ const EVENT_ICONS: Record<EventType, React.ReactNode> = {
game_choice: <Gamepad2 className="w-5 h-5" />, game_choice: <Gamepad2 className="w-5 h-5" />,
} }
const EVENT_COLORS: Record<EventType, string> = { const EVENT_COLORS: Record<EventType, {
golden_hour: 'from-yellow-500/20 to-yellow-600/20 border-yellow-500/50 text-yellow-400', gradient: string
common_enemy: 'from-red-500/20 to-red-600/20 border-red-500/50 text-red-400', border: string
double_risk: 'from-purple-500/20 to-purple-600/20 border-purple-500/50 text-purple-400', text: string
jackpot: 'from-green-500/20 to-green-600/20 border-green-500/50 text-green-400', glow: string
swap: 'from-blue-500/20 to-blue-600/20 border-blue-500/50 text-blue-400', iconBg: string
game_choice: 'from-orange-500/20 to-orange-600/20 border-orange-500/50 text-orange-400', }> = {
golden_hour: {
gradient: 'from-yellow-500/20 via-yellow-500/10 to-transparent',
border: 'border-yellow-500/50',
text: 'text-yellow-400',
glow: 'shadow-[0_0_30px_rgba(234,179,8,0.3)]',
iconBg: 'bg-yellow-500/20',
},
common_enemy: {
gradient: 'from-red-500/20 via-red-500/10 to-transparent',
border: 'border-red-500/50',
text: 'text-red-400',
glow: 'shadow-[0_0_30px_rgba(239,68,68,0.3)]',
iconBg: 'bg-red-500/20',
},
double_risk: {
gradient: 'from-purple-500/20 via-purple-500/10 to-transparent',
border: 'border-purple-500/50',
text: 'text-purple-400',
glow: 'shadow-[0_0_20px_rgba(139,92,246,0.25)]',
iconBg: 'bg-purple-500/20',
},
jackpot: {
gradient: 'from-green-500/20 via-green-500/10 to-transparent',
border: 'border-green-500/50',
text: 'text-green-400',
glow: 'shadow-[0_0_30px_rgba(34,197,94,0.3)]',
iconBg: 'bg-green-500/20',
},
swap: {
gradient: 'from-neon-500/20 via-neon-500/10 to-transparent',
border: 'border-neon-500/50',
text: 'text-neon-400',
glow: 'shadow-[0_0_20px_rgba(34,211,238,0.25)]',
iconBg: 'bg-neon-500/20',
},
game_choice: {
gradient: 'from-orange-500/20 via-orange-500/10 to-transparent',
border: 'border-orange-500/50',
text: 'text-orange-400',
glow: 'shadow-[0_0_30px_rgba(249,115,22,0.3)]',
iconBg: 'bg-orange-500/20',
},
} }
function formatTime(seconds: number): string { function formatTime(seconds: number): string {
@@ -68,42 +110,53 @@ export function EventBanner({ activeEvent, onRefresh }: EventBannerProps) {
const event = activeEvent.event const event = activeEvent.event
const info = EVENT_INFO[event.type] const info = EVENT_INFO[event.type]
const icon = EVENT_ICONS[event.type] const icon = EVENT_ICONS[event.type]
const colorClass = EVENT_COLORS[event.type] const colors = EVENT_COLORS[event.type]
return ( return (
<div <div
className={` className={`
relative overflow-hidden rounded-xl border p-4 relative overflow-hidden rounded-2xl border p-5
bg-gradient-to-r ${colorClass} glass ${colors.border} ${colors.glow}
animate-pulse-slow animate-pulse-slow
`} `}
> >
{/* Animated background effect */} {/* Background gradient */}
<div className={`absolute inset-0 bg-gradient-to-r ${colors.gradient}`} />
{/* Animated shimmer effect */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent -translate-x-full animate-shimmer" /> <div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent -translate-x-full animate-shimmer" />
<div className="relative flex items-center justify-between"> {/* Grid pattern */}
<div className="flex items-center gap-3"> <div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.02)_1px,transparent_1px)] bg-[size:20px_20px]" />
<div className="p-2 rounded-lg bg-white/10">
<div className="relative flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-4">
<div className={`p-3 rounded-xl ${colors.iconBg} ${colors.text}`}>
{icon} {icon}
</div> </div>
<div> <div>
<h3 className="font-bold text-lg">{info.name}</h3> <div className="flex items-center gap-2 mb-1">
<p className="text-sm opacity-80">{info.description}</p> <h3 className={`font-bold text-lg ${colors.text}`}>{info.name}</h3>
<Sparkles className={`w-4 h-4 ${colors.text} animate-pulse`} />
</div>
<p className="text-sm text-gray-400">{info.description}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-4">
{activeEvent.effects.points_multiplier !== 1.0 && (
<div className={`px-4 py-2 rounded-xl ${colors.iconBg} font-bold ${colors.text} border ${colors.border}`}>
x{activeEvent.effects.points_multiplier}
</div>
)}
{timeRemaining !== null && timeRemaining > 0 && ( {timeRemaining !== null && timeRemaining > 0 && (
<div className="flex items-center gap-2 text-lg font-mono font-bold"> <div className={`flex items-center gap-2 px-4 py-2 rounded-xl bg-dark-700/50 border border-dark-600 font-mono font-bold ${colors.text}`}>
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
{formatTime(timeRemaining)} {formatTime(timeRemaining)}
</div> </div>
)} )}
{activeEvent.effects.points_multiplier !== 1.0 && (
<div className="px-3 py-1 rounded-full bg-white/10 font-bold">
x{activeEvent.effects.points_multiplier}
</div> </div>
)}
</div> </div>
</div> </div>
) )

View File

@@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Play, Square } from 'lucide-react' import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Play, Square, Sparkles } from 'lucide-react'
import { Button } from '@/components/ui' import { NeonButton } from '@/components/ui'
import { eventsApi } from '@/api' import { eventsApi } from '@/api'
import type { ActiveEvent, EventType, Challenge } from '@/types' import type { ActiveEvent, EventType, Challenge } from '@/types'
import { EVENT_INFO } from '@/types' import { EVENT_INFO } from '@/types'
@@ -24,12 +24,21 @@ const EVENT_TYPES: EventType[] = [
] ]
const EVENT_ICONS: Record<EventType, React.ReactNode> = { const EVENT_ICONS: Record<EventType, React.ReactNode> = {
golden_hour: <Zap className="w-4 h-4" />, golden_hour: <Zap className="w-5 h-5" />,
common_enemy: <Users className="w-4 h-4" />, common_enemy: <Users className="w-5 h-5" />,
double_risk: <Shield className="w-4 h-4" />, double_risk: <Shield className="w-5 h-5" />,
jackpot: <Gift className="w-4 h-4" />, jackpot: <Gift className="w-5 h-5" />,
swap: <ArrowLeftRight className="w-4 h-4" />, swap: <ArrowLeftRight className="w-5 h-5" />,
game_choice: <Gamepad2 className="w-4 h-4" />, game_choice: <Gamepad2 className="w-5 h-5" />,
}
const EVENT_COLORS: Record<EventType, { selected: string; icon: string }> = {
golden_hour: { selected: 'border-yellow-500/50 bg-yellow-500/10', icon: 'text-yellow-400' },
common_enemy: { selected: 'border-red-500/50 bg-red-500/10', icon: 'text-red-400' },
double_risk: { selected: 'border-purple-500/50 bg-purple-500/10', icon: 'text-purple-400' },
jackpot: { selected: 'border-green-500/50 bg-green-500/10', icon: 'text-green-400' },
swap: { selected: 'border-neon-500/50 bg-neon-500/10', icon: 'text-neon-400' },
game_choice: { selected: 'border-orange-500/50 bg-orange-500/10', icon: 'text-orange-400' },
} }
// Default durations for events (in minutes) // Default durations for events (in minutes)
@@ -107,54 +116,81 @@ export function EventControl({
} }
if (activeEvent.event) { if (activeEvent.event) {
const colors = EVENT_COLORS[activeEvent.event.type]
return ( return (
<div className="p-4 bg-gray-800 rounded-xl"> <div className={`glass rounded-xl p-4 border ${colors.selected}`}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<div className={`p-2 rounded-lg bg-white/10 ${colors.icon}`}>
{EVENT_ICONS[activeEvent.event.type]} {EVENT_ICONS[activeEvent.event.type]}
<span className="font-medium">
Активно: {EVENT_INFO[activeEvent.event.type].name}
</span>
</div> </div>
<Button <div>
variant="danger" <span className="font-semibold text-white">
{EVENT_INFO[activeEvent.event.type].name}
</span>
<span className="text-gray-400 text-sm ml-2">активно</span>
</div>
</div>
<NeonButton
variant="outline"
size="sm" size="sm"
onClick={handleStop} onClick={handleStop}
isLoading={isStopping} isLoading={isStopping}
className="border-red-500/50 text-red-400 hover:bg-red-500/10"
icon={<Square className="w-4 h-4" />}
> >
<Square className="w-4 h-4 mr-1" />
Остановить Остановить
</Button> </NeonButton>
</div> </div>
</div> </div>
) )
} }
return ( return (
<div className="p-4 bg-gray-800 rounded-xl space-y-4"> <div className="glass rounded-xl p-5 space-y-5">
<h3 className="font-bold text-white">Запустить событие</h3> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
<Sparkles 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="grid grid-cols-2 sm:grid-cols-3 gap-2"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{EVENT_TYPES.map((type) => ( {EVENT_TYPES.map((type) => {
const colors = EVENT_COLORS[type]
const isSelected = selectedType === type
return (
<button <button
key={type} key={type}
onClick={() => handleTypeChange(type)} onClick={() => handleTypeChange(type)}
className={` className={`
p-3 rounded-lg border-2 transition-all text-left relative p-4 rounded-xl border-2 transition-all duration-300 text-left
${selectedType === type ${isSelected
? 'border-primary-500 bg-primary-500/10' ? `${colors.selected} shadow-lg`
: 'border-gray-700 hover:border-gray-600'} : 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`} `}
> >
<div className="flex items-center gap-2 mb-1"> <div className={`flex items-center gap-2 mb-2 ${isSelected ? colors.icon : 'text-gray-400'}`}>
{EVENT_ICONS[type]} {EVENT_ICONS[type]}
<span className="font-medium text-sm">{EVENT_INFO[type].name}</span> <span className={`font-semibold text-sm ${isSelected ? 'text-white' : 'text-gray-300'}`}>
{EVENT_INFO[type].name}
</span>
</div> </div>
<p className="text-xs text-gray-400 line-clamp-2"> <p className="text-xs text-gray-500 line-clamp-2">
{EVENT_INFO[type].description} {EVENT_INFO[type].description}
</p> </p>
{isSelected && (
<div className="absolute top-2 right-2">
<div className={`w-2 h-2 rounded-full ${colors.icon.replace('text-', 'bg-')} animate-pulse`} />
</div>
)}
</button> </button>
))} )
})}
</div> </div>
{/* Duration setting */} {/* Duration setting */}
@@ -170,9 +206,9 @@ export function EventControl({
min={1} min={1}
max={480} max={480}
placeholder={`По умолчанию: ${DEFAULT_DURATIONS[selectedType]}`} placeholder={`По умолчанию: ${DEFAULT_DURATIONS[selectedType]}`}
className="w-full p-2 bg-gray-700 border border-gray-600 rounded-lg text-white" className="input w-full"
/> />
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1.5">
Оставьте пустым для значения по умолчанию ({DEFAULT_DURATIONS[selectedType]} мин) Оставьте пустым для значения по умолчанию ({DEFAULT_DURATIONS[selectedType]} мин)
</p> </p>
</div> </div>
@@ -186,7 +222,7 @@ export function EventControl({
<select <select
value={selectedChallengeId || ''} value={selectedChallengeId || ''}
onChange={(e) => setSelectedChallengeId(e.target.value ? Number(e.target.value) : null)} onChange={(e) => setSelectedChallengeId(e.target.value ? Number(e.target.value) : null)}
className="w-full p-2 bg-gray-700 border border-gray-600 rounded-lg text-white" className="input w-full"
> >
<option value=""> Выберите челлендж </option> <option value=""> Выберите челлендж </option>
{challenges.map((c) => ( {challenges.map((c) => (
@@ -198,15 +234,15 @@ export function EventControl({
</div> </div>
)} )}
<Button <NeonButton
onClick={handleStart} onClick={handleStart}
isLoading={isStarting} isLoading={isStarting}
disabled={selectedType === 'common_enemy' && !selectedChallengeId} disabled={selectedType === 'common_enemy' && !selectedChallengeId}
className="w-full" className="w-full"
icon={<Play className="w-4 h-4" />}
> >
<Play className="w-4 h-4 mr-2" />
Запустить {EVENT_INFO[selectedType].name} Запустить {EVENT_INFO[selectedType].name}
</Button> </NeonButton>
</div> </div>
) )
} }

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useCallback, useMemo } from 'react'
import type { Game } from '@/types' import type { Game } from '@/types'
import { Gamepad2, Loader2 } from 'lucide-react'
interface SpinWheelProps { interface SpinWheelProps {
games: Game[] games: Game[]
@@ -8,33 +9,80 @@ interface SpinWheelProps {
disabled?: boolean disabled?: boolean
} }
const ITEM_HEIGHT = 100 const SPIN_DURATION = 5000 // ms
const VISIBLE_ITEMS = 5 const EXTRA_ROTATIONS = 5
const SPIN_DURATION = 4000
const EXTRA_ROTATIONS = 3 // Цветовая палитра секторов
const SECTOR_COLORS = [
{ bg: '#0d9488', border: '#14b8a6' }, // teal
{ bg: '#7c3aed', border: '#8b5cf6' }, // violet
{ bg: '#0891b2', border: '#06b6d4' }, // cyan
{ bg: '#c026d3', border: '#d946ef' }, // fuchsia
{ bg: '#059669', border: '#10b981' }, // emerald
{ bg: '#7c2d12', border: '#ea580c' }, // orange
{ bg: '#1d4ed8', border: '#3b82f6' }, // blue
{ bg: '#be123c', border: '#e11d48' }, // rose
]
export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheelProps) { export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheelProps) {
const [isSpinning, setIsSpinning] = useState(false) const [isSpinning, setIsSpinning] = useState(false)
const [offset, setOffset] = useState(0) const [rotation, setRotation] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const animationRef = useRef<number | null>(null)
// Create extended list for seamless looping // Размеры колеса
const extendedGames = [...games, ...games, ...games, ...games, ...games] const wheelSize = 400
const centerX = wheelSize / 2
const centerY = wheelSize / 2
const radius = wheelSize / 2 - 10
// Рассчитываем углы секторов
const sectorAngle = games.length > 0 ? 360 / games.length : 360
// Создаём path для сектора
const createSectorPath = useCallback((index: number, total: number) => {
const angle = 360 / total
const startAngle = index * angle - 90 // Начинаем сверху
const endAngle = startAngle + angle
const startRad = (startAngle * Math.PI) / 180
const endRad = (endAngle * Math.PI) / 180
const x1 = centerX + radius * Math.cos(startRad)
const y1 = centerY + radius * Math.sin(startRad)
const x2 = centerX + radius * Math.cos(endRad)
const y2 = centerY + radius * Math.sin(endRad)
const largeArcFlag = angle > 180 ? 1 : 0
return `M ${centerX} ${centerY} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${x2} ${y2} Z`
}, [centerX, centerY, radius])
// Позиция текста в секторе
const getTextPosition = useCallback((index: number, total: number) => {
const angle = 360 / total
const midAngle = index * angle + angle / 2 - 90
const midRad = (midAngle * Math.PI) / 180
const textRadius = radius * 0.65
return {
x: centerX + textRadius * Math.cos(midRad),
y: centerY + textRadius * Math.sin(midRad),
rotation: midAngle + 90, // Текст читается от центра к краю
}
}, [centerX, centerY, radius])
const handleSpin = useCallback(async () => { const handleSpin = useCallback(async () => {
if (isSpinning || disabled || games.length === 0) return if (isSpinning || disabled || games.length === 0) return
setIsSpinning(true) setIsSpinning(true)
// Get result from API first // Получаем результат от API
const resultGame = await onSpin() const resultGame = await onSpin()
if (!resultGame) { if (!resultGame) {
setIsSpinning(false) setIsSpinning(false)
return return
} }
// Find target index // Находим индекс выигравшей игры
const targetIndex = games.findIndex(g => g.id === resultGame.id) const targetIndex = games.findIndex(g => g.id === resultGame.id)
if (targetIndex === -1) { if (targetIndex === -1) {
setIsSpinning(false) setIsSpinning(false)
@@ -42,168 +90,245 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
return return
} }
// Calculate animation // Рассчитываем угол для остановки
const totalItems = games.length // Указатель находится сверху (на 0°/360°)
const fullRotations = EXTRA_ROTATIONS * totalItems // Нам нужно чтобы нужный сектор оказался под указателем
const finalPosition = (fullRotations + targetIndex) * ITEM_HEIGHT const targetSectorMidAngle = targetIndex * sectorAngle + sectorAngle / 2
// Animate // Полные обороты + угол до центра сектора
const startTime = Date.now() // Колесо крутится по часовой стрелке, указатель сверху
const startOffset = offset % (totalItems * ITEM_HEIGHT) // Чтобы сектор оказался сверху, нужно повернуть на (360 - targetSectorMidAngle)
const baseRotation = rotation % 360
const fullRotations = EXTRA_ROTATIONS * 360
const finalAngle = fullRotations + (360 - targetSectorMidAngle) + baseRotation
const animate = () => { setRotation(rotation + finalAngle)
const elapsed = Date.now() - startTime
const progress = Math.min(elapsed / SPIN_DURATION, 1)
// Easing function - starts fast, slows down at end // Ждём окончания анимации
const easeOut = 1 - Math.pow(1 - progress, 4) setTimeout(() => {
const currentOffset = startOffset + (finalPosition - startOffset) * easeOut
setOffset(currentOffset)
if (progress < 1) {
animationRef.current = requestAnimationFrame(animate)
} else {
setIsSpinning(false) setIsSpinning(false)
onSpinComplete(resultGame) onSpinComplete(resultGame)
} }, SPIN_DURATION)
}, [isSpinning, disabled, games, rotation, sectorAngle, onSpin, onSpinComplete])
// Сокращаем название игры для отображения
const truncateText = (text: string, maxLength: number) => {
if (text.length <= maxLength) return text
return text.slice(0, maxLength - 2) + '...'
} }
animationRef.current = requestAnimationFrame(animate) // Мемоизируем секторы для производительности
}, [isSpinning, disabled, games, offset, onSpin, onSpinComplete]) const sectors = useMemo(() => {
return games.map((game, index) => {
const color = SECTOR_COLORS[index % SECTOR_COLORS.length]
const path = createSectorPath(index, games.length)
const textPos = getTextPosition(index, games.length)
const maxTextLength = games.length > 8 ? 10 : games.length > 5 ? 14 : 18
useEffect(() => { return { game, color, path, textPos, maxTextLength }
return () => { })
if (animationRef.current) { }, [games, createSectorPath, getTextPosition])
cancelAnimationFrame(animationRef.current)
}
}
}, [])
if (games.length === 0) { if (games.length === 0) {
return ( return (
<div className="text-center py-12 text-gray-400"> <div className="glass rounded-2xl p-12 text-center">
Нет доступных игр для прокрутки <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">Нет доступных игр для прокрутки</p>
</div> </div>
) )
} }
const containerHeight = VISIBLE_ITEMS * ITEM_HEIGHT
const currentIndex = Math.round(offset / ITEM_HEIGHT) % games.length
// Calculate opacity based on distance from center
const getItemOpacity = (itemIndex: number) => {
const itemPosition = itemIndex * ITEM_HEIGHT - offset
const centerPosition = containerHeight / 2 - ITEM_HEIGHT / 2
const distanceFromCenter = Math.abs(itemPosition - centerPosition)
const maxDistance = containerHeight / 2
const opacity = Math.max(0, 1 - (distanceFromCenter / maxDistance) * 0.8)
return opacity
}
return ( return (
<div className="flex flex-col items-center gap-6"> <div className="flex flex-col items-center gap-6">
{/* Wheel container */} {/* Контейнер колеса */}
<div className="relative w-full max-w-md"> <div className="relative">
{/* Selection indicator */} {/* Внешнее свечение */}
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-[100px] border-2 border-primary-500 rounded-lg bg-primary-500/10 z-20 pointer-events-none"> <div className={`
<div className="absolute -left-3 top-1/2 -translate-y-1/2 w-0 h-0 border-t-8 border-b-8 border-r-8 border-t-transparent border-b-transparent border-r-primary-500" /> absolute -inset-4 rounded-full transition-all duration-500
<div className="absolute -right-3 top-1/2 -translate-y-1/2 w-0 h-0 border-t-8 border-b-8 border-l-8 border-t-transparent border-b-transparent border-l-primary-500" /> ${isSpinning
? 'bg-neon-500/30 blur-2xl animate-pulse'
: 'bg-neon-500/10 blur-xl'
}
`} />
{/* Указатель (стрелка сверху) */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-2 z-20">
<div className={`
relative transition-all duration-300
${isSpinning ? 'scale-110' : ''}
`}>
{/* Свечение указателя */}
<div className={`
absolute inset-0 blur-sm transition-opacity duration-300
${isSpinning ? 'opacity-100' : 'opacity-50'}
`}>
<svg width="40" height="50" viewBox="0 0 40 50">
<path
d="M20 50 L5 15 L20 0 L35 15 Z"
fill="#22d3ee"
/>
</svg>
</div>
{/* Указатель */}
<svg width="40" height="50" viewBox="0 0 40 50" className="relative">
<defs>
<linearGradient id="pointerGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#22d3ee" />
<stop offset="100%" stopColor="#0891b2" />
</linearGradient>
</defs>
<path
d="M20 50 L5 15 L20 0 L35 15 Z"
fill="url(#pointerGradient)"
stroke="#67e8f9"
strokeWidth="2"
/>
</svg>
</div>
</div> </div>
{/* Items container */} {/* Колесо */}
<div <div
ref={containerRef} className="relative"
className="relative overflow-hidden" style={{ width: wheelSize, height: wheelSize }}
style={{ height: containerHeight }}
> >
<div {/* Внешний ободок */}
className="absolute w-full transition-none" <div className={`
absolute inset-0 rounded-full
border-4 transition-all duration-300
${isSpinning
? 'border-neon-400 shadow-[0_0_30px_rgba(34,211,238,0.5),inset_0_0_30px_rgba(34,211,238,0.1)]'
: 'border-neon-500/50 shadow-[0_0_15px_rgba(34,211,238,0.2)]'
}
`} />
{/* SVG колесо */}
<svg
width={wheelSize}
height={wheelSize}
className="relative z-10 transition-transform"
style={{ style={{
transform: `translateY(${containerHeight / 2 - ITEM_HEIGHT / 2 - offset}px)`, transform: `rotate(${rotation}deg)`,
transitionProperty: isSpinning ? 'transform' : 'none',
transitionDuration: isSpinning ? `${SPIN_DURATION}ms` : '0ms',
transitionTimingFunction: 'cubic-bezier(0.17, 0.67, 0.12, 0.99)',
}} }}
> >
{extendedGames.map((game, index) => { <defs>
const realIndex = index % games.length {/* Тени для секторов */}
const isSelected = !isSpinning && realIndex === currentIndex <filter id="sectorShadow" x="-20%" y="-20%" width="140%" height="140%">
const opacity = getItemOpacity(index) <feDropShadow dx="0" dy="0" stdDeviation="2" floodColor="#000" floodOpacity="0.3" />
</filter>
</defs>
return ( {/* Секторы */}
<div {sectors.map(({ game, color, path, textPos, maxTextLength }, index) => (
key={`${game.id}-${index}`} <g key={game.id}>
className={`flex items-center gap-4 px-4 transition-transform duration-200 ${ {/* Сектор */}
isSelected ? 'scale-105' : '' <path
}`} d={path}
style={{ height: ITEM_HEIGHT, opacity }} fill={color.bg}
> stroke={color.border}
{/* Game cover */} strokeWidth="2"
<div className="w-16 h-16 rounded-lg overflow-hidden bg-gray-700 flex-shrink-0"> filter="url(#sectorShadow)"
{game.cover_url ? (
<img
src={game.cover_url}
alt={game.title}
className="w-full h-full object-cover"
/> />
) : (
<div className="w-full h-full flex items-center justify-center text-2xl">
🎮
</div>
)}
</div>
{/* Game info */} {/* Текст названия игры */}
<div className="flex-1 min-w-0"> <text
<h3 className="font-bold text-white truncate text-lg"> x={textPos.x}
{game.title} y={textPos.y}
</h3> transform={`rotate(${textPos.rotation}, ${textPos.x}, ${textPos.y})`}
{game.genre && ( textAnchor="middle"
<p className="text-sm text-gray-400 truncate">{game.genre}</p> dominantBaseline="middle"
)} fill="white"
</div> fontSize={games.length > 10 ? "10" : games.length > 6 ? "11" : "13"}
</div> fontWeight="bold"
) style={{
})} textShadow: '0 1px 3px rgba(0,0,0,0.8)',
</div> pointerEvents: 'none',
</div> }}
</div> >
{truncateText(game.title, maxTextLength)}
</text>
{/* Spin button */} {/* Разделительная линия */}
<line
x1={centerX}
y1={centerY}
x2={centerX + radius * Math.cos((index * sectorAngle - 90) * Math.PI / 180)}
y2={centerY + radius * Math.sin((index * sectorAngle - 90) * Math.PI / 180)}
stroke="rgba(255,255,255,0.3)"
strokeWidth="1"
/>
</g>
))}
{/* Центральный круг */}
<circle
cx={centerX}
cy={centerY}
r="50"
fill="url(#centerGradient)"
stroke="#22d3ee"
strokeWidth="3"
/>
<defs>
<radialGradient id="centerGradient" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#1e293b" />
<stop offset="100%" stopColor="#0f172a" />
</radialGradient>
</defs>
</svg>
{/* Кнопка КРУТИТЬ в центре */}
<button <button
onClick={handleSpin} onClick={handleSpin}
disabled={isSpinning || disabled} disabled={isSpinning || disabled}
className={` className={`
relative px-12 py-4 text-xl font-bold rounded-full absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
transition-all duration-300 transform w-24 h-24 rounded-full z-20
${isSpinning || disabled flex flex-col items-center justify-center gap-1
? 'bg-gray-700 text-gray-400 cursor-not-allowed' font-bold text-sm uppercase tracking-wider
: 'bg-gradient-to-r from-primary-500 to-primary-600 text-white hover:scale-105 hover:shadow-lg hover:shadow-primary-500/30 active:scale-95' transition-all duration-300
disabled:cursor-not-allowed
${isSpinning
? 'bg-dark-800 text-neon-400 shadow-[0_0_20px_rgba(34,211,238,0.4)]'
: 'bg-gradient-to-br from-neon-500 to-cyan-600 text-white hover:shadow-[0_0_30px_rgba(34,211,238,0.6)] hover:scale-105 active:scale-95'
} }
${disabled && !isSpinning ? 'opacity-50' : ''}
`} `}
> >
{isSpinning ? ( {isSpinning ? (
<span className="flex items-center gap-2"> <Loader2 className="w-8 h-8 animate-spin" />
<svg className="animate-spin w-6 h-6" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Крутится...
</span>
) : ( ) : (
'КРУТИТЬ!' <>
<span className="text-xs">КРУТИТЬ</span>
</>
)} )}
</button> </button>
</div> </div>
{/* Декоративные элементы при вращении */}
{isSpinning && (
<>
<div className="absolute inset-0 rounded-full border-2 border-neon-400/30 animate-ping" />
<div
className="absolute inset-0 rounded-full border border-accent-400/20"
style={{ animation: 'ping 1s cubic-bezier(0, 0, 0.2, 1) infinite 0.5s' }}
/>
</>
)}
</div>
{/* Подсказка */}
<p className={`
text-sm transition-all duration-300
${isSpinning ? 'text-neon-400 animate-pulse' : 'text-gray-500'}
`}>
{isSpinning ? 'Колесо вращается...' : 'Нажмите на колесо, чтобы крутить!'}
</p>
</div>
) )
} }

View File

@@ -125,8 +125,8 @@ export function TelegramLink() {
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
className={`p-2 rounded-lg transition-colors ${ className={`p-2 rounded-lg transition-colors ${
isLinked isLinked
? 'text-blue-400 hover:text-blue-300 hover:bg-gray-700' ? 'text-blue-400 hover:text-blue-300 hover:bg-dark-700'
: 'text-gray-400 hover:text-white hover:bg-gray-700' : 'text-gray-400 hover:text-white hover:bg-dark-700'
}`} }`}
title={isLinked ? 'Telegram привязан' : 'Привязать Telegram'} title={isLinked ? 'Telegram привязан' : 'Привязать Telegram'}
> >
@@ -134,17 +134,17 @@ export function TelegramLink() {
</button> </button>
{isOpen && ( {isOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-xl max-w-md w-full p-6 relative"> <div className="glass rounded-xl max-w-md w-full p-6 relative border border-dark-600">
<button <button
onClick={handleClose} onClick={handleClose}
className="absolute top-4 right-4 text-gray-400 hover:text-white" className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-blue-500/20 rounded-full flex items-center justify-center"> <div className="w-12 h-12 bg-blue-500/10 rounded-full flex items-center justify-center border border-blue-500/30">
<MessageCircle className="w-6 h-6 text-blue-400" /> <MessageCircle className="w-6 h-6 text-blue-400" />
</div> </div>
<div> <div>
@@ -171,7 +171,7 @@ export function TelegramLink() {
)} )}
{/* User Profile Card */} {/* User Profile Card */}
<div className="p-4 bg-gradient-to-br from-gray-700/50 to-gray-800/50 rounded-xl border border-gray-600/50"> <div className="p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Avatar - Telegram avatar */} {/* Avatar - Telegram avatar */}
<div className="relative"> <div className="relative">
@@ -182,12 +182,12 @@ export function TelegramLink() {
className="w-12 h-12 rounded-full object-cover border-2 border-blue-500/50" className="w-12 h-12 rounded-full object-cover border-2 border-blue-500/50"
/> />
) : ( ) : (
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center border-2 border-blue-500/50"> <div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-accent-500 flex items-center justify-center border-2 border-blue-500/50">
<User className="w-6 h-6 text-white" /> <User className="w-6 h-6 text-white" />
</div> </div>
)} )}
{/* Link indicator */} {/* Link indicator */}
<div className="absolute -bottom-1 -right-1 w-5 h-5 bg-green-500 rounded-full flex items-center justify-center border-2 border-gray-800"> <div className="absolute -bottom-1 -right-1 w-5 h-5 bg-green-500 rounded-full flex items-center justify-center border-2 border-dark-800">
<Link2 className="w-2.5 h-2.5 text-white" /> <Link2 className="w-2.5 h-2.5 text-white" />
</div> </div>
</div> </div>
@@ -205,7 +205,7 @@ export function TelegramLink() {
</div> </div>
{/* Notifications Info */} {/* Notifications Info */}
<div className="p-4 bg-gray-700/30 rounded-lg"> <div className="p-4 bg-dark-700/30 rounded-lg border border-dark-600/50">
<p className="text-sm text-gray-300 font-medium mb-3">Уведомления включены:</p> <p className="text-sm text-gray-300 font-medium mb-3">Уведомления включены:</p>
<div className="grid grid-cols-1 gap-2"> <div className="grid grid-cols-1 gap-2">
<div className="flex items-center gap-2 text-sm text-gray-400"> <div className="flex items-center gap-2 text-sm text-gray-400">
@@ -254,7 +254,7 @@ export function TelegramLink() {
<button <button
onClick={handleOpenBot} onClick={handleOpenBot}
className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2" className="w-full py-3 px-4 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 border border-blue-500/50 hover:border-blue-500 rounded-lg font-medium transition-all duration-200 flex items-center justify-center gap-2"
> >
<ExternalLink className="w-5 h-5" /> <ExternalLink className="w-5 h-5" />
Открыть Telegram снова Открыть Telegram снова
@@ -268,13 +268,13 @@ export function TelegramLink() {
<button <button
onClick={handleOpenBot} onClick={handleOpenBot}
className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2" className="w-full py-3 px-4 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 border border-blue-500/50 hover:border-blue-500 rounded-lg font-medium transition-all duration-200 flex items-center justify-center gap-2"
> >
<ExternalLink className="w-5 h-5" /> <ExternalLink className="w-5 h-5" />
Открыть Telegram Открыть Telegram
</button> </button>
<p className="text-sm text-gray-500 text-center"> <p className="text-sm text-gray-400 text-center">
Ссылка действительна 10 минут Ссылка действительна 10 минут
</p> </p>
</> </>
@@ -304,7 +304,7 @@ export function TelegramLink() {
<button <button
onClick={handleGenerateLink} onClick={handleGenerateLink}
disabled={loading} disabled={loading}
className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50 flex items-center justify-center gap-2" className="w-full py-3 px-4 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 border border-blue-500/50 hover:border-blue-500 rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
{loading ? ( {loading ? (
<Loader2 className="w-5 h-5 animate-spin" /> <Loader2 className="w-5 h-5 animate-spin" />

View File

@@ -1,42 +1,88 @@
import { Outlet, Link, useNavigate } from 'react-router-dom' import { useState, useEffect } from 'react'
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { Gamepad2, LogOut, Trophy, User } from 'lucide-react' import { Gamepad2, LogOut, Trophy, User, Menu, X } from 'lucide-react'
import { TelegramLink } from '@/components/TelegramLink' import { TelegramLink } from '@/components/TelegramLink'
import { clsx } from 'clsx'
export function Layout() { export function Layout() {
const { user, isAuthenticated, logout } = useAuthStore() const { user, isAuthenticated, logout } = useAuthStore()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const [isScrolled, setIsScrolled] = useState(false)
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 10)
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
// Close mobile menu on route change
useEffect(() => {
setIsMobileMenuOpen(false)
}, [location])
const handleLogout = () => { const handleLogout = () => {
logout() logout()
navigate('/login') navigate('/login')
} }
const isActiveLink = (path: string) => location.pathname === path
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
{/* Header */} {/* Header */}
<header className="bg-gray-800 border-b border-gray-700"> <header
className={clsx(
'fixed top-0 left-0 right-0 z-50 transition-all duration-300',
isScrolled
? 'bg-dark-900/80 backdrop-blur-lg border-b border-dark-600/50 shadow-lg'
: 'bg-transparent'
)}
>
<div className="container mx-auto px-4 py-4 flex items-center justify-between"> <div className="container mx-auto px-4 py-4 flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 text-xl font-bold text-white"> {/* Logo */}
<Gamepad2 className="w-8 h-8 text-primary-500" /> <Link
<span>Игровой Марафон</span> to="/"
className="flex items-center gap-3 group"
>
<div className="relative">
<Gamepad2 className="w-8 h-8 text-neon-500 transition-all duration-300 group-hover:text-neon-400 group-hover:drop-shadow-[0_0_8px_rgba(34,211,238,0.6)]" />
</div>
<span className="text-xl font-bold text-white font-display tracking-wider glitch-hover">
МАРАФОН
</span>
</Link> </Link>
<nav className="flex items-center gap-4"> {/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-6">
{isAuthenticated ? ( {isAuthenticated ? (
<> <>
<Link <Link
to="/marathons" to="/marathons"
className="flex items-center gap-2 text-gray-300 hover:text-white transition-colors" className={clsx(
'flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200',
isActiveLink('/marathons')
? 'text-neon-400 bg-neon-500/10'
: 'text-gray-300 hover:text-white hover:bg-dark-700'
)}
> >
<Trophy className="w-5 h-5" /> <Trophy className="w-5 h-5" />
<span>Марафоны</span> <span>Марафоны</span>
</Link> </Link>
<div className="flex items-center gap-3 ml-4 pl-4 border-l border-gray-700"> <div className="flex items-center gap-3 ml-2 pl-4 border-l border-dark-600">
<Link <Link
to="/profile" to="/profile"
className="flex items-center gap-2 text-gray-300 hover:text-white transition-colors" className={clsx(
'flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200',
isActiveLink('/profile')
? 'text-neon-400 bg-neon-500/10'
: 'text-gray-300 hover:text-white hover:bg-dark-700'
)}
> >
<User className="w-5 h-5" /> <User className="w-5 h-5" />
<span>{user?.nickname}</span> <span>{user?.nickname}</span>
@@ -46,7 +92,7 @@ export function Layout() {
<button <button
onClick={handleLogout} onClick={handleLogout}
className="p-2 text-gray-400 hover:text-white transition-colors" className="p-2 text-gray-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-all duration-200"
title="Выйти" title="Выйти"
> >
<LogOut className="w-5 h-5" /> <LogOut className="w-5 h-5" />
@@ -55,27 +101,114 @@ export function Layout() {
</> </>
) : ( ) : (
<> <>
<Link to="/login" className="text-gray-300 hover:text-white transition-colors"> <Link
to="/login"
className="text-gray-300 hover:text-white transition-colors px-4 py-2"
>
Войти Войти
</Link> </Link>
<Link to="/register" className="btn btn-primary"> <Link
to="/register"
className="px-4 py-2 bg-neon-500 hover:bg-neon-400 text-dark-900 font-semibold rounded-lg transition-all duration-200 shadow-[0_0_10px_rgba(34,211,238,0.25)] hover:shadow-[0_0_16px_rgba(34,211,238,0.4)]"
>
Регистрация Регистрация
</Link> </Link>
</> </>
)} )}
</nav> </nav>
{/* Mobile Menu Button */}
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="md:hidden p-2 text-gray-300 hover:text-white rounded-lg hover:bg-dark-700 transition-colors"
>
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
</div> </div>
{/* Mobile Menu */}
{isMobileMenuOpen && (
<div className="md:hidden bg-dark-800/95 backdrop-blur-lg border-t border-dark-600 animate-slide-in-down">
<div className="container mx-auto px-4 py-4 space-y-2">
{isAuthenticated ? (
<>
<Link
to="/marathons"
className={clsx(
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
isActiveLink('/marathons')
? 'text-neon-400 bg-neon-500/10'
: 'text-gray-300 hover:text-white hover:bg-dark-700'
)}
>
<Trophy className="w-5 h-5" />
<span>Марафоны</span>
</Link>
<Link
to="/profile"
className={clsx(
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
isActiveLink('/profile')
? 'text-neon-400 bg-neon-500/10'
: 'text-gray-300 hover:text-white hover:bg-dark-700'
)}
>
<User className="w-5 h-5" />
<span>{user?.nickname}</span>
</Link>
<div className="pt-2 border-t border-dark-600">
<button
onClick={handleLogout}
className="flex items-center gap-3 w-full px-4 py-3 text-red-400 hover:bg-red-500/10 rounded-lg transition-all"
>
<LogOut className="w-5 h-5" />
<span>Выйти</span>
</button>
</div>
</>
) : (
<>
<Link
to="/login"
className="block px-4 py-3 text-gray-300 hover:text-white hover:bg-dark-700 rounded-lg transition-all"
>
Войти
</Link>
<Link
to="/register"
className="block px-4 py-3 text-center bg-neon-500 hover:bg-neon-400 text-dark-900 font-semibold rounded-lg transition-all"
>
Регистрация
</Link>
</>
)}
</div>
</div>
)}
</header> </header>
{/* Spacer for fixed header */}
<div className="h-[72px]" />
{/* Main content */} {/* Main content */}
<main className="flex-1 container mx-auto px-4 py-8"> <main className="flex-1 container mx-auto px-4 py-8">
<Outlet /> <Outlet />
</main> </main>
{/* Footer */} {/* Footer */}
<footer className="bg-gray-800 border-t border-gray-700 py-4"> <footer className="bg-dark-800/50 border-t border-dark-600/50 py-6">
<div className="container mx-auto px-4 text-center text-gray-500 text-sm"> <div className="container mx-auto px-4">
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2 text-gray-500">
<Gamepad2 className="w-5 h-5 text-neon-500/50" />
<span className="text-sm">
Игровой Марафон &copy; {new Date().getFullYear()} Игровой Марафон &copy; {new Date().getFullYear()}
</span>
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span className="text-neon-500/50">v1.0</span>
</div>
</div>
</div> </div>
</footer> </footer>
</div> </div>

View File

@@ -15,13 +15,13 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
ref={ref} ref={ref}
disabled={disabled || isLoading} disabled={disabled || isLoading}
className={clsx( className={clsx(
'inline-flex items-center justify-center font-medium rounded-lg transition-colors', 'inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200',
'disabled:opacity-50 disabled:cursor-not-allowed', 'disabled:opacity-50 disabled:cursor-not-allowed',
{ {
'bg-primary-600 hover:bg-primary-700 text-white': variant === 'primary', 'bg-neon-500 hover:bg-neon-400 text-dark-900 font-semibold shadow-[0_0_8px_rgba(34,211,238,0.25)] hover:shadow-[0_0_14px_rgba(34,211,238,0.4)]': variant === 'primary',
'bg-gray-700 hover:bg-gray-600 text-white': variant === 'secondary', 'bg-dark-600 hover:bg-dark-500 text-white border border-dark-500': variant === 'secondary',
'bg-red-600 hover:bg-red-700 text-white': variant === 'danger', 'bg-red-600 hover:bg-red-700 text-white': variant === 'danger',
'bg-transparent hover:bg-gray-800 text-gray-300': variant === 'ghost', 'bg-transparent hover:bg-dark-700 text-gray-300 hover:text-white': variant === 'ghost',
'px-3 py-1.5 text-sm': size === 'sm', 'px-3 py-1.5 text-sm': size === 'sm',
'px-4 py-2 text-base': size === 'md', 'px-4 py-2 text-base': size === 'md',
'px-6 py-3 text-lg': size === 'lg', 'px-6 py-3 text-lg': size === 'lg',

View File

@@ -4,11 +4,18 @@ import { clsx } from 'clsx'
interface CardProps { interface CardProps {
children: ReactNode children: ReactNode
className?: string className?: string
hover?: boolean
} }
export function Card({ children, className }: CardProps) { export function Card({ children, className, hover = false }: CardProps) {
return ( return (
<div className={clsx('bg-gray-800 rounded-xl p-6 shadow-lg', className)}> <div
className={clsx(
'bg-dark-800 rounded-xl p-6 border border-dark-600',
hover && 'transition-all duration-300 hover:-translate-y-1 hover:border-neon-500/30 hover:shadow-[0_10px_40px_rgba(34,211,238,0.08)]',
className
)}
>
{children} {children}
</div> </div>
) )

View File

@@ -2,7 +2,7 @@ import { useEffect } from 'react'
import { AlertTriangle, Info, Trash2, X } from 'lucide-react' import { AlertTriangle, Info, Trash2, X } from 'lucide-react'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { useConfirmStore, type ConfirmVariant } from '@/store/confirm' import { useConfirmStore, type ConfirmVariant } from '@/store/confirm'
import { Button } from './Button' import { NeonButton } from './NeonButton'
const icons: Record<ConfirmVariant, React.ReactNode> = { const icons: Record<ConfirmVariant, React.ReactNode> = {
danger: <Trash2 className="w-6 h-6" />, danger: <Trash2 className="w-6 h-6" />,
@@ -11,15 +11,15 @@ const icons: Record<ConfirmVariant, React.ReactNode> = {
} }
const iconStyles: Record<ConfirmVariant, string> = { const iconStyles: Record<ConfirmVariant, string> = {
danger: 'bg-red-500/20 text-red-500', danger: 'bg-red-500/10 text-red-400 border border-red-500/30',
warning: 'bg-yellow-500/20 text-yellow-500', warning: 'bg-yellow-500/10 text-yellow-400 border border-yellow-500/30',
info: 'bg-blue-500/20 text-blue-500', info: 'bg-neon-500/10 text-neon-400 border border-neon-500/30',
} }
const buttonVariants: Record<ConfirmVariant, 'danger' | 'primary' | 'secondary'> = { const confirmButtonStyles: Record<ConfirmVariant, string> = {
danger: 'danger', danger: 'border-red-500/50 text-red-400 hover:bg-red-500/10 hover:border-red-500',
warning: 'primary', warning: 'border-yellow-500/50 text-yellow-400 hover:bg-yellow-500/10 hover:border-yellow-500',
info: 'primary', info: '', // Will use NeonButton default
} }
export function ConfirmModal() { export function ConfirmModal() {
@@ -62,7 +62,7 @@ export function ConfirmModal() {
/> />
{/* Modal */} {/* Modal */}
<div className="relative bg-gray-800 rounded-xl shadow-2xl max-w-md w-full animate-in zoom-in-95 fade-in duration-200 border border-gray-700"> <div className="relative glass rounded-xl shadow-2xl max-w-md w-full animate-in zoom-in-95 fade-in duration-200 border border-dark-600">
{/* Close button */} {/* Close button */}
<button <button
onClick={handleCancel} onClick={handleCancel}
@@ -89,20 +89,31 @@ export function ConfirmModal() {
{/* Actions */} {/* Actions */}
<div className="flex gap-3"> <div className="flex gap-3">
<Button <NeonButton
variant="secondary" variant="secondary"
className="flex-1" className="flex-1"
onClick={handleCancel} onClick={handleCancel}
> >
{options.cancelText || 'Отмена'} {options.cancelText || 'Отмена'}
</Button> </NeonButton>
<Button {variant === 'info' ? (
variant={buttonVariants[variant]} <NeonButton
className="flex-1" className="flex-1"
onClick={handleConfirm} onClick={handleConfirm}
> >
{options.confirmText || 'Подтвердить'} {options.confirmText || 'Подтвердить'}
</Button> </NeonButton>
) : (
<button
className={clsx(
'flex-1 px-4 py-2.5 rounded-lg font-semibold transition-all duration-200 border-2 bg-transparent',
confirmButtonStyles[variant]
)}
onClick={handleConfirm}
>
{options.confirmText || 'Подтвердить'}
</button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,215 @@
import { type ReactNode, type HTMLAttributes } from 'react'
import { clsx } from 'clsx'
interface GlassCardProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode
variant?: 'default' | 'dark' | 'neon' | 'gradient'
hover?: boolean
glow?: boolean
className?: string
}
export function GlassCard({
children,
variant = 'default',
hover = false,
glow = false,
className,
...props
}: GlassCardProps) {
const variantClasses = {
default: 'glass',
dark: 'glass-dark',
neon: 'glass-neon',
gradient: 'gradient-border',
}
return (
<div
className={clsx(
'rounded-xl p-6',
variantClasses[variant],
hover && 'card-hover cursor-pointer',
glow && 'neon-glow-pulse',
className
)}
{...props}
>
{children}
</div>
)
}
// Stats card variant
interface StatsCardProps {
label: string
value: string | number
icon?: ReactNode
trend?: {
value: number
isPositive: boolean
}
color?: 'neon' | 'purple' | 'pink' | 'default'
className?: string
}
export function StatsCard({
label,
value,
icon,
trend,
color = 'default',
className,
}: StatsCardProps) {
const colorClasses = {
neon: 'border-neon-500/30 hover:border-neon-500/50',
purple: 'border-accent-500/30 hover:border-accent-500/50',
pink: 'border-pink-500/30 hover:border-pink-500/50',
default: 'border-dark-600 hover:border-dark-500',
}
const iconColorClasses = {
neon: 'text-neon-500 bg-neon-500/10',
purple: 'text-accent-500 bg-accent-500/10',
pink: 'text-pink-500 bg-pink-500/10',
default: 'text-gray-400 bg-dark-700',
}
const valueColorClasses = {
neon: 'text-neon-400',
purple: 'text-accent-400',
pink: 'text-pink-400',
default: 'text-white',
}
return (
<div
className={clsx(
'glass rounded-xl p-4 border transition-all duration-300',
colorClasses[color],
'hover:-translate-y-0.5',
className
)}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="text-sm text-gray-400 mb-1">{label}</p>
<p className={clsx(
'font-bold',
typeof value === 'number' ? 'text-2xl' : 'text-lg',
valueColorClasses[color]
)}>
{value}
</p>
{trend && (
<p
className={clsx(
'text-xs mt-1',
trend.isPositive ? 'text-green-400' : 'text-red-400'
)}
>
{trend.isPositive ? '+' : ''}{trend.value}%
</p>
)}
</div>
{icon && (
<div
className={clsx(
'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0',
iconColorClasses[color]
)}
>
{icon}
</div>
)}
</div>
</div>
)
}
// Feature card variant
interface FeatureCardProps {
title: string
description: string
icon: ReactNode
color?: 'neon' | 'purple' | 'pink'
className?: string
}
export function FeatureCard({
title,
description,
icon,
color = 'neon',
className,
}: FeatureCardProps) {
const colorClasses = {
neon: {
icon: 'text-neon-500 bg-neon-500/10 group-hover:bg-neon-500/20',
border: 'group-hover:border-neon-500/50',
glow: 'group-hover:shadow-[0_0_20px_rgba(34,211,238,0.12)]',
},
purple: {
icon: 'text-accent-500 bg-accent-500/10 group-hover:bg-accent-500/20',
border: 'group-hover:border-accent-500/50',
glow: 'group-hover:shadow-[0_0_20px_rgba(139,92,246,0.12)]',
},
pink: {
icon: 'text-pink-500 bg-pink-500/10 group-hover:bg-pink-500/20',
border: 'group-hover:border-pink-500/50',
glow: 'group-hover:shadow-[0_0_20px_rgba(244,114,182,0.12)]',
},
}
const colors = colorClasses[color]
return (
<div
className={clsx(
'group glass rounded-xl p-6 border border-dark-600 transition-all duration-300',
'hover:-translate-y-1',
colors.border,
colors.glow,
className
)}
>
<div
className={clsx(
'w-14 h-14 rounded-xl flex items-center justify-center mb-4 transition-colors',
colors.icon
)}
>
{icon}
</div>
<h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
<p className="text-gray-400 text-sm">{description}</p>
</div>
)
}
// Interactive card with animated border
interface AnimatedBorderCardProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode
className?: string
}
export function AnimatedBorderCard({
children,
className,
...props
}: AnimatedBorderCardProps) {
return (
<div className={clsx('relative group', className)} {...props}>
{/* Animated gradient border */}
<div
className="absolute -inset-0.5 bg-gradient-to-r from-neon-500 via-accent-500 to-pink-500 rounded-xl opacity-30 group-hover:opacity-60 blur transition-opacity duration-300"
style={{
backgroundSize: '200% 200%',
animation: 'gradient-flow 3s linear infinite',
}}
/>
{/* Card content */}
<div className="relative glass-dark rounded-xl p-6">{children}</div>
</div>
)
}

View File

@@ -0,0 +1,116 @@
import { type ReactNode, type HTMLAttributes } from 'react'
import { clsx } from 'clsx'
interface GlitchTextProps extends HTMLAttributes<HTMLSpanElement> {
children: ReactNode
as?: 'span' | 'h1' | 'h2' | 'h3' | 'h4' | 'p'
intensity?: 'low' | 'medium' | 'high'
color?: 'neon' | 'purple' | 'pink' | 'white'
hover?: boolean
className?: string
}
export function GlitchText({
children,
as: Component = 'span',
intensity = 'medium',
color = 'neon',
hover = false,
className,
...props
}: GlitchTextProps) {
const text = typeof children === 'string' ? children : ''
const colorClasses = {
neon: 'text-neon-500',
purple: 'text-accent-500',
pink: 'text-pink-500',
white: 'text-white',
}
const glowClasses = {
neon: 'neon-text',
purple: 'neon-text-purple',
pink: '[text-shadow:0_0_8px_rgba(244,114,182,0.5),0_0_16px_rgba(244,114,182,0.25)]',
white: '[text-shadow:0_0_8px_rgba(255,255,255,0.4),0_0_16px_rgba(255,255,255,0.2)]',
}
const intensityClasses = {
low: 'animate-[glitch-skew_2s_infinite_linear_alternate-reverse]',
medium: '',
high: 'animate-glitch',
}
if (hover) {
return (
<Component
className={clsx(
colorClasses[color],
'relative inline-block cursor-pointer transition-all duration-300',
'hover:' + glowClasses[color],
'glitch-hover',
className
)}
{...props}
>
{children}
</Component>
)
}
return (
<Component
className={clsx(
'glitch relative inline-block',
colorClasses[color],
glowClasses[color],
intensityClasses[intensity],
className
)}
data-text={text}
{...props}
>
{children}
</Component>
)
}
// Simpler glitch effect for headings
interface GlitchHeadingProps {
children: ReactNode
level?: 1 | 2 | 3 | 4
className?: string
gradient?: boolean
}
export function GlitchHeading({
children,
level = 1,
className,
gradient = false,
}: GlitchHeadingProps) {
const text = typeof children === 'string' ? children : ''
const sizeClasses = {
1: 'text-4xl md:text-5xl lg:text-6xl font-bold',
2: 'text-3xl md:text-4xl font-bold',
3: 'text-2xl md:text-3xl font-semibold',
4: 'text-xl md:text-2xl font-semibold',
}
const Component = `h${level}` as keyof JSX.IntrinsicElements
return (
<Component
className={clsx(
'glitch relative inline-block',
sizeClasses[level],
gradient ? 'gradient-neon-text' : 'text-white neon-text',
className
)}
data-text={text}
>
{children}
</Component>
)
}

View File

@@ -11,7 +11,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
return ( return (
<div className="w-full"> <div className="w-full">
{label && ( {label && (
<label htmlFor={id} className="block text-sm font-medium text-gray-300 mb-1"> <label htmlFor={id} className="block text-sm font-medium text-gray-300 mb-1.5">
{label} {label}
</label> </label>
)} )}
@@ -19,15 +19,16 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
ref={ref} ref={ref}
id={id} id={id}
className={clsx( className={clsx(
'w-full px-4 py-2 bg-gray-800 border rounded-lg text-white placeholder-gray-500', 'w-full px-4 py-3 bg-dark-800 border rounded-lg text-white placeholder-gray-500',
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent', 'focus:outline-none focus:border-neon-500',
'transition-colors', 'focus:shadow-[0_0_0_3px_rgba(34,211,238,0.1),0_0_8px_rgba(34,211,238,0.15)]',
error ? 'border-red-500' : 'border-gray-700', 'transition-all duration-200',
error ? 'border-red-500' : 'border-dark-600',
className className
)} )}
{...props} {...props}
/> />
{error && <p className="mt-1 text-sm text-red-500">{error}</p>} {error && <p className="mt-1.5 text-sm text-red-400">{error}</p>}
</div> </div>
) )
} }

View File

@@ -0,0 +1,174 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'
import { clsx } from 'clsx'
import { Loader2 } from 'lucide-react'
interface NeonButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg'
color?: 'neon' | 'purple' | 'pink'
isLoading?: boolean
icon?: ReactNode
iconPosition?: 'left' | 'right'
glow?: boolean
pulse?: boolean
}
export const NeonButton = forwardRef<HTMLButtonElement, NeonButtonProps>(
(
{
className,
variant = 'primary',
size = 'md',
color = 'neon',
isLoading,
icon,
iconPosition = 'left',
glow = true,
pulse = false,
children,
disabled,
...props
},
ref
) => {
const colorMap = {
neon: {
primary: 'bg-neon-500 hover:bg-neon-400 text-dark-900',
secondary: 'bg-dark-600 hover:bg-dark-500 text-neon-400 border border-neon-500/30',
outline: 'bg-transparent border-2 border-neon-500 text-neon-500 hover:bg-neon-500 hover:text-dark-900',
ghost: 'bg-transparent hover:bg-neon-500/10 text-neon-400',
danger: 'bg-red-600 hover:bg-red-700 text-white',
glow: '0 0 12px rgba(34, 211, 238, 0.4)',
glowHover: '0 0 18px rgba(34, 211, 238, 0.55)',
glowDanger: '0 0 12px rgba(239, 68, 68, 0.4)',
glowDangerHover: '0 0 18px rgba(239, 68, 68, 0.55)',
},
purple: {
primary: 'bg-accent-500 hover:bg-accent-400 text-white',
secondary: 'bg-dark-600 hover:bg-dark-500 text-accent-400 border border-accent-500/30',
outline: 'bg-transparent border-2 border-accent-500 text-accent-500 hover:bg-accent-500 hover:text-white',
ghost: 'bg-transparent hover:bg-accent-500/10 text-accent-400',
danger: 'bg-red-600 hover:bg-red-700 text-white',
glow: '0 0 12px rgba(139, 92, 246, 0.4)',
glowHover: '0 0 18px rgba(139, 92, 246, 0.55)',
glowDanger: '0 0 12px rgba(239, 68, 68, 0.4)',
glowDangerHover: '0 0 18px rgba(239, 68, 68, 0.55)',
},
pink: {
primary: 'bg-pink-500 hover:bg-pink-400 text-white',
secondary: 'bg-dark-600 hover:bg-dark-500 text-pink-400 border border-pink-500/30',
outline: 'bg-transparent border-2 border-pink-500 text-pink-500 hover:bg-pink-500 hover:text-white',
ghost: 'bg-transparent hover:bg-pink-500/10 text-pink-400',
danger: 'bg-red-600 hover:bg-red-700 text-white',
glow: '0 0 12px rgba(244, 114, 182, 0.4)',
glowHover: '0 0 18px rgba(244, 114, 182, 0.55)',
glowDanger: '0 0 12px rgba(239, 68, 68, 0.4)',
glowDangerHover: '0 0 18px rgba(239, 68, 68, 0.55)',
},
}
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm gap-1.5',
md: 'px-4 py-2.5 text-base gap-2',
lg: 'px-6 py-3 text-lg gap-2.5',
}
const iconSizes = {
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6',
}
const colors = colorMap[color]
return (
<button
ref={ref}
disabled={disabled || isLoading}
className={clsx(
'inline-flex items-center justify-center font-semibold rounded-lg',
'transition-all duration-300 ease-out',
'disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none',
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-dark-900',
color === 'neon' && 'focus:ring-neon-500',
color === 'purple' && 'focus:ring-accent-500',
color === 'pink' && 'focus:ring-pink-500',
colors[variant],
sizeClasses[size],
pulse && 'neon-glow-pulse',
className
)}
style={{
boxShadow: glow && !disabled && variant !== 'ghost'
? (variant === 'danger' ? colors.glowDanger : colors.glow)
: undefined,
}}
onMouseEnter={(e) => {
if (glow && !disabled && variant !== 'ghost') {
e.currentTarget.style.boxShadow = variant === 'danger' ? colors.glowDangerHover : colors.glowHover
}
props.onMouseEnter?.(e)
}}
onMouseLeave={(e) => {
if (glow && !disabled && variant !== 'ghost') {
e.currentTarget.style.boxShadow = variant === 'danger' ? colors.glowDanger : colors.glow
}
props.onMouseLeave?.(e)
}}
{...props}
>
{isLoading && <Loader2 className={clsx(iconSizes[size], 'animate-spin')} />}
{!isLoading && icon && iconPosition === 'left' && (
<span className={iconSizes[size]}>{icon}</span>
)}
{children}
{!isLoading && icon && iconPosition === 'right' && (
<span className={iconSizes[size]}>{icon}</span>
)}
</button>
)
}
)
NeonButton.displayName = 'NeonButton'
// Gradient button variant
interface GradientButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
size?: 'sm' | 'md' | 'lg'
isLoading?: boolean
icon?: ReactNode
}
export const GradientButton = forwardRef<HTMLButtonElement, GradientButtonProps>(
({ className, size = 'md', isLoading, icon, children, disabled, ...props }, ref) => {
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm gap-1.5',
md: 'px-4 py-2.5 text-base gap-2',
lg: 'px-6 py-3 text-lg gap-2.5',
}
return (
<button
ref={ref}
disabled={disabled || isLoading}
className={clsx(
'relative inline-flex items-center justify-center font-semibold rounded-lg',
'bg-gradient-to-r from-neon-500 via-accent-500 to-pink-500',
'text-white transition-all duration-300',
'hover:shadow-[0_0_20px_rgba(139,92,246,0.35)]',
'disabled:opacity-50 disabled:cursor-not-allowed',
'focus:outline-none focus:ring-2 focus:ring-accent-500 focus:ring-offset-2 focus:ring-offset-dark-900',
sizeClasses[size],
className
)}
{...props}
>
{isLoading && <Loader2 className="w-5 h-5 animate-spin" />}
{!isLoading && icon && <span className="w-5 h-5">{icon}</span>}
{children}
</button>
)
}
)
GradientButton.displayName = 'GradientButton'

View File

@@ -3,6 +3,8 @@ import { usersApi } from '@/api'
// Глобальный кэш для blob URL аватарок // Глобальный кэш для blob URL аватарок
const avatarCache = new Map<number, string>() const avatarCache = new Map<number, string>()
// Пользователи, для которых нужно сбросить HTTP-кэш при следующем запросе
const needsCacheBust = new Set<number>()
interface UserAvatarProps { interface UserAvatarProps {
userId: number userId: number
@@ -10,6 +12,7 @@ interface UserAvatarProps {
nickname: string nickname: string
size?: 'sm' | 'md' | 'lg' size?: 'sm' | 'md' | 'lg'
className?: string className?: string
version?: number // Для принудительного обновления при смене аватара
} }
const sizeClasses = { const sizeClasses = {
@@ -18,7 +21,7 @@ const sizeClasses = {
lg: 'w-24 h-24 text-xl', lg: 'w-24 h-24 text-xl',
} }
export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className = '' }: UserAvatarProps) { export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className = '', version = 0 }: UserAvatarProps) {
const [blobUrl, setBlobUrl] = useState<string | null>(null) const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [failed, setFailed] = useState(false) const [failed, setFailed] = useState(false)
@@ -28,16 +31,31 @@ export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className
return return
} }
// Проверяем кэш // Если version > 0, значит аватар обновился - сбрасываем кэш
const shouldBustCache = version > 0 || needsCacheBust.has(userId)
// Проверяем кэш только если не нужен bust
if (!shouldBustCache) {
const cached = avatarCache.get(userId) const cached = avatarCache.get(userId)
if (cached) { if (cached) {
setBlobUrl(cached) setBlobUrl(cached)
return return
} }
}
// Очищаем старый кэш если bust
if (shouldBustCache) {
const cached = avatarCache.get(userId)
if (cached) {
URL.revokeObjectURL(cached)
avatarCache.delete(userId)
}
needsCacheBust.delete(userId)
}
// Загружаем аватарку // Загружаем аватарку
let cancelled = false let cancelled = false
usersApi.getAvatarUrl(userId) usersApi.getAvatarUrl(userId, shouldBustCache)
.then(url => { .then(url => {
if (!cancelled) { if (!cancelled) {
avatarCache.set(userId, url) avatarCache.set(userId, url)
@@ -53,7 +71,7 @@ export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className
return () => { return () => {
cancelled = true cancelled = true
} }
}, [userId, hasAvatar]) }, [userId, hasAvatar, version])
const sizeClass = sizeClasses[size] const sizeClass = sizeClasses[size]
@@ -84,4 +102,6 @@ export function clearAvatarCache(userId: number) {
URL.revokeObjectURL(cached) URL.revokeObjectURL(cached)
avatarCache.delete(userId) avatarCache.delete(userId)
} }
// Помечаем, что при следующем запросе нужно сбросить HTTP-кэш браузера
needsCacheBust.add(userId)
} }

View File

@@ -4,3 +4,8 @@ export { Card, CardHeader, CardTitle, CardContent } from './Card'
export { ToastContainer } from './Toast' export { ToastContainer } from './Toast'
export { ConfirmModal } from './ConfirmModal' export { ConfirmModal } from './ConfirmModal'
export { UserAvatar, clearAvatarCache } from './UserAvatar' export { UserAvatar, clearAvatarCache } from './UserAvatar'
// New design system components
export { GlitchText, GlitchHeading } from './GlitchText'
export { NeonButton, GradientButton } from './NeonButton'
export { GlassCard, StatsCard, FeatureCard, AnimatedBorderCard } from './GlassCard'

View File

@@ -2,11 +2,129 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
body { /* ========================================
@apply bg-gray-900 text-gray-100 min-h-screen; CSS Variables
======================================== */
:root {
/* Base colors - slightly warmer dark tones */
--color-dark-950: #08090d;
--color-dark-900: #0d0e14;
--color-dark-800: #14161e;
--color-dark-700: #1c1e28;
--color-dark-600: #252732;
--color-dark-500: #2e313d;
/* Soft cyan (primary) - gentler on eyes */
--color-neon-500: #22d3ee;
--color-neon-400: #67e8f9;
--color-neon-600: #06b6d4;
/* Soft violet accent */
--color-accent-500: #8b5cf6;
--color-accent-600: #7c3aed;
--color-accent-700: #6d28d9;
/* Soft pink highlight - used sparingly */
--color-pink-500: #f472b6;
/* Glow colors - reduced intensity */
--glow-neon: 0 0 8px rgba(34, 211, 238, 0.4), 0 0 16px rgba(34, 211, 238, 0.2);
--glow-neon-lg: 0 0 12px rgba(34, 211, 238, 0.5), 0 0 24px rgba(34, 211, 238, 0.3);
--glow-purple: 0 0 8px rgba(139, 92, 246, 0.4), 0 0 16px rgba(139, 92, 246, 0.2);
--glow-pink: 0 0 8px rgba(244, 114, 182, 0.4), 0 0 16px rgba(244, 114, 182, 0.2);
/* Text glow - subtle */
--text-glow-neon: 0 0 8px rgba(34, 211, 238, 0.5), 0 0 16px rgba(34, 211, 238, 0.25);
--text-glow-purple: 0 0 8px rgba(139, 92, 246, 0.5), 0 0 16px rgba(139, 92, 246, 0.25);
} }
/* Custom scrollbar styles */ /* ========================================
Base Styles
======================================== */
html {
scroll-behavior: smooth;
}
body {
@apply bg-dark-900 text-gray-100 min-h-screen antialiased;
font-family: 'Inter', system-ui, sans-serif;
background-image:
linear-gradient(rgba(34, 211, 238, 0.015) 1px, transparent 1px),
linear-gradient(90deg, rgba(34, 211, 238, 0.015) 1px, transparent 1px);
background-size: 50px 50px;
background-attachment: fixed;
}
/* Noise overlay - can be added to any element */
.noise-overlay::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.03;
pointer-events: none;
z-index: 9999;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
}
/* Autofill styles - override browser defaults */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 30px #14161e inset !important;
-webkit-text-fill-color: #fff !important;
caret-color: #fff;
transition: background-color 5000s ease-in-out 0s;
}
/* ========================================
Selection Styles
======================================== */
::selection {
background: rgba(34, 211, 238, 0.25);
color: #fff;
}
::-moz-selection {
background: rgba(34, 211, 238, 0.25);
color: #fff;
}
/* ========================================
Custom Scrollbar (Neon Style)
======================================== */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-dark-800);
}
::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, var(--color-neon-500), var(--color-accent-500));
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, var(--color-neon-400), var(--color-accent-600));
}
::-webkit-scrollbar-corner {
background: var(--color-dark-800);
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-neon-500) var(--color-dark-800);
}
/* Custom scrollbar class for specific elements */
.custom-scrollbar::-webkit-scrollbar { .custom-scrollbar::-webkit-scrollbar {
width: 6px; width: 6px;
} }
@@ -16,46 +134,450 @@ body {
} }
.custom-scrollbar::-webkit-scrollbar-thumb { .custom-scrollbar::-webkit-scrollbar-thumb {
background: #4b5563; background: var(--color-dark-500);
border-radius: 3px; border-radius: 3px;
} }
.custom-scrollbar::-webkit-scrollbar-thumb:hover { .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #6b7280; background: var(--color-neon-500);
} }
/* Firefox */ /* ========================================
.custom-scrollbar { Glitch Effect
scrollbar-width: thin; ======================================== */
scrollbar-color: #4b5563 transparent; .glitch {
position: relative;
animation: glitch-skew 1s infinite linear alternate-reverse;
} }
.glitch::before,
.glitch::after {
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.glitch::before {
left: 2px;
text-shadow: -2px 0 rgba(139, 92, 246, 0.7);
clip: rect(44px, 450px, 56px, 0);
animation: glitch-anim 5s infinite linear alternate-reverse;
}
.glitch::after {
left: -2px;
text-shadow: -2px 0 rgba(34, 211, 238, 0.7), 2px 2px rgba(139, 92, 246, 0.7);
clip: rect(44px, 450px, 56px, 0);
animation: glitch-anim2 5s infinite linear alternate-reverse;
}
@keyframes glitch-anim {
0% { clip: rect(31px, 9999px, 94px, 0); transform: skew(0.85deg); }
5% { clip: rect(70px, 9999px, 71px, 0); transform: skew(0.07deg); }
10% { clip: rect(29px, 9999px, 24px, 0); transform: skew(0.22deg); }
15% { clip: rect(69px, 9999px, 63px, 0); transform: skew(0.52deg); }
20% { clip: rect(13px, 9999px, 71px, 0); transform: skew(0.72deg); }
25% { clip: rect(39px, 9999px, 89px, 0); transform: skew(0.24deg); }
30% { clip: rect(87px, 9999px, 98px, 0); transform: skew(0.63deg); }
35% { clip: rect(63px, 9999px, 16px, 0); transform: skew(0.15deg); }
40% { clip: rect(92px, 9999px, 4px, 0); transform: skew(0.83deg); }
45% { clip: rect(67px, 9999px, 72px, 0); transform: skew(0.19deg); }
50% { clip: rect(43px, 9999px, 21px, 0); transform: skew(0.74deg); }
55% { clip: rect(75px, 9999px, 54px, 0); transform: skew(0.28deg); }
60% { clip: rect(17px, 9999px, 86px, 0); transform: skew(0.91deg); }
65% { clip: rect(51px, 9999px, 32px, 0); transform: skew(0.46deg); }
70% { clip: rect(29px, 9999px, 69px, 0); transform: skew(0.38deg); }
75% { clip: rect(84px, 9999px, 11px, 0); transform: skew(0.67deg); }
80% { clip: rect(38px, 9999px, 82px, 0); transform: skew(0.12deg); }
85% { clip: rect(61px, 9999px, 47px, 0); transform: skew(0.54deg); }
90% { clip: rect(22px, 9999px, 91px, 0); transform: skew(0.33deg); }
95% { clip: rect(79px, 9999px, 28px, 0); transform: skew(0.79deg); }
100% { clip: rect(56px, 9999px, 65px, 0); transform: skew(0.41deg); }
}
@keyframes glitch-anim2 {
0% { clip: rect(65px, 9999px, 100px, 0); transform: skew(0.63deg); }
5% { clip: rect(52px, 9999px, 74px, 0); transform: skew(0.29deg); }
10% { clip: rect(79px, 9999px, 85px, 0); transform: skew(0.84deg); }
15% { clip: rect(43px, 9999px, 27px, 0); transform: skew(0.17deg); }
20% { clip: rect(16px, 9999px, 92px, 0); transform: skew(0.56deg); }
25% { clip: rect(88px, 9999px, 36px, 0); transform: skew(0.39deg); }
30% { clip: rect(32px, 9999px, 68px, 0); transform: skew(0.71deg); }
35% { clip: rect(71px, 9999px, 13px, 0); transform: skew(0.23deg); }
40% { clip: rect(24px, 9999px, 57px, 0); transform: skew(0.92deg); }
45% { clip: rect(83px, 9999px, 41px, 0); transform: skew(0.48deg); }
50% { clip: rect(19px, 9999px, 79px, 0); transform: skew(0.35deg); }
55% { clip: rect(67px, 9999px, 23px, 0); transform: skew(0.76deg); }
60% { clip: rect(45px, 9999px, 96px, 0); transform: skew(0.14deg); }
65% { clip: rect(91px, 9999px, 51px, 0); transform: skew(0.58deg); }
70% { clip: rect(28px, 9999px, 83px, 0); transform: skew(0.87deg); }
75% { clip: rect(76px, 9999px, 19px, 0); transform: skew(0.26deg); }
80% { clip: rect(53px, 9999px, 67px, 0); transform: skew(0.69deg); }
85% { clip: rect(14px, 9999px, 89px, 0); transform: skew(0.43deg); }
90% { clip: rect(62px, 9999px, 34px, 0); transform: skew(0.81deg); }
95% { clip: rect(37px, 9999px, 76px, 0); transform: skew(0.52deg); }
100% { clip: rect(86px, 9999px, 48px, 0); transform: skew(0.31deg); }
}
@keyframes glitch-skew {
0% { transform: skew(-2deg); }
10% { transform: skew(1deg); }
20% { transform: skew(-1deg); }
30% { transform: skew(0deg); }
40% { transform: skew(2deg); }
50% { transform: skew(-1deg); }
60% { transform: skew(1deg); }
70% { transform: skew(0deg); }
80% { transform: skew(-2deg); }
90% { transform: skew(1deg); }
100% { transform: skew(0deg); }
}
/* Simpler glitch for hover states */
.glitch-hover:hover {
animation: glitch-simple 0.3s ease;
}
@keyframes glitch-simple {
0%, 100% { transform: translate(0); }
20% { transform: translate(-2px, 2px); }
40% { transform: translate(-2px, -2px); }
60% { transform: translate(2px, 2px); }
80% { transform: translate(2px, -2px); }
}
/* ========================================
Neon Glow Effects
======================================== */
.neon-glow {
box-shadow: var(--glow-neon);
}
.neon-glow-lg {
box-shadow: var(--glow-neon-lg);
}
.neon-glow-purple {
box-shadow: var(--glow-purple);
}
.neon-glow-pink {
box-shadow: var(--glow-pink);
}
.neon-text {
text-shadow: var(--text-glow-neon);
}
.neon-text-purple {
text-shadow: var(--text-glow-purple);
}
/* Animated glow */
.neon-glow-pulse {
animation: neon-pulse 2s ease-in-out infinite;
}
@keyframes neon-pulse {
0%, 100% {
box-shadow: 0 0 6px rgba(34, 211, 238, 0.4), 0 0 12px rgba(34, 211, 238, 0.2);
}
50% {
box-shadow: 0 0 10px rgba(34, 211, 238, 0.5), 0 0 20px rgba(34, 211, 238, 0.3);
}
}
/* ========================================
Glass Effect (Glassmorphism)
======================================== */
.glass {
background: rgba(18, 18, 26, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.glass-dark {
background: rgba(10, 10, 15, 0.8);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.glass-neon {
background: rgba(20, 22, 30, 0.6);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(34, 211, 238, 0.15);
box-shadow: inset 0 0 20px rgba(34, 211, 238, 0.03);
}
/* ========================================
Gradient Utilities
======================================== */
.gradient-neon {
background: linear-gradient(135deg, #22d3ee, #8b5cf6);
}
.gradient-neon-text {
background: linear-gradient(135deg, #22d3ee, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.gradient-pink-purple {
background: linear-gradient(135deg, #f472b6, #8b5cf6);
}
.gradient-dark {
background: linear-gradient(180deg, var(--color-dark-900), var(--color-dark-950));
}
/* Animated gradient border */
.gradient-border {
position: relative;
background: var(--color-dark-800);
border-radius: 12px;
}
.gradient-border::before {
content: '';
position: absolute;
inset: -2px;
background: linear-gradient(90deg, #22d3ee, #8b5cf6, #f472b6, #22d3ee);
background-size: 300% 300%;
border-radius: 14px;
z-index: -1;
animation: gradient-flow 3s linear infinite;
}
@keyframes gradient-flow {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* ========================================
Shimmer Effect
======================================== */
.shimmer {
position: relative;
overflow: hidden;
}
.shimmer::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.1),
transparent
);
animation: shimmer 2s infinite;
}
@keyframes shimmer {
100% { left: 100%; }
}
/* ========================================
Component Layer
======================================== */
@layer components { @layer components {
/* Buttons */
.btn { .btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed; @apply px-4 py-2 rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
} }
.btn-primary { .btn-primary {
@apply bg-primary-600 hover:bg-primary-700 text-white; @apply bg-neon-500 hover:bg-neon-400 text-dark-900 font-semibold;
box-shadow: 0 0 8px rgba(34, 211, 238, 0.25);
}
.btn-primary:hover {
box-shadow: 0 0 14px rgba(34, 211, 238, 0.4);
} }
.btn-secondary { .btn-secondary {
@apply bg-gray-700 hover:bg-gray-600 text-white; @apply bg-dark-600 hover:bg-dark-500 text-white border border-dark-500;
} }
.btn-danger { .btn-danger {
@apply bg-red-600 hover:bg-red-700 text-white; @apply bg-red-600 hover:bg-red-700 text-white;
} }
.btn-ghost {
@apply bg-transparent hover:bg-dark-700 text-gray-300 hover:text-white;
}
.btn-neon {
@apply relative bg-transparent border-2 border-neon-500 text-neon-500 font-semibold overflow-hidden;
transition: all 0.3s ease;
}
.btn-neon:hover {
@apply text-dark-900;
background: var(--color-neon-500);
box-shadow: 0 0 14px rgba(34, 211, 238, 0.4);
}
/* Inputs */
.input { .input {
@apply w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent; @apply w-full px-4 py-3 bg-dark-800 border border-dark-600 rounded-lg text-white placeholder-gray-500 transition-all duration-200;
} }
.input:focus {
@apply outline-none border-neon-500;
box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.1), 0 0 8px rgba(34, 211, 238, 0.15);
}
/* Cards */
.card { .card {
@apply bg-gray-800 rounded-xl p-6 shadow-lg; @apply bg-dark-800 rounded-xl p-6 border border-dark-600;
} }
.card-glass {
@apply rounded-xl p-6;
background: rgba(20, 22, 30, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.card-hover {
@apply transition-all duration-300;
}
.card-hover:hover {
@apply -translate-y-1;
box-shadow: 0 10px 40px rgba(34, 211, 238, 0.08);
border-color: rgba(34, 211, 238, 0.25);
}
/* Links */
.link { .link {
@apply text-primary-400 hover:text-primary-300 transition-colors; @apply text-neon-500 hover:text-neon-400 transition-colors;
}
/* Badges */
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-neon {
@apply bg-neon-500/20 text-neon-400 border border-neon-500/30;
}
.badge-purple {
@apply bg-accent-500/20 text-accent-400 border border-accent-500/30;
}
.badge-pink {
@apply bg-pink-500/20 text-pink-400 border border-pink-500/30;
}
/* Dividers */
.divider {
@apply border-t border-dark-600;
}
.divider-glow {
@apply border-t border-neon-500/30;
box-shadow: 0 0 8px rgba(34, 211, 238, 0.15);
}
}
/* ========================================
Utility Animations
======================================== */
.hover-lift {
@apply transition-transform duration-300;
}
.hover-lift:hover {
@apply -translate-y-1;
}
.hover-glow {
@apply transition-shadow duration-300;
}
.hover-glow:hover {
box-shadow: 0 0 14px rgba(34, 211, 238, 0.25);
}
.hover-border-glow {
@apply transition-all duration-300;
}
.hover-border-glow:hover {
border-color: rgba(34, 211, 238, 0.4);
box-shadow: 0 0 12px rgba(34, 211, 238, 0.15);
}
/* Stagger children animations */
.stagger-children > * {
@apply animate-slide-in-up;
animation-fill-mode: both;
}
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
.stagger-children > *:nth-child(2) { animation-delay: 50ms; }
.stagger-children > *:nth-child(3) { animation-delay: 100ms; }
.stagger-children > *:nth-child(4) { animation-delay: 150ms; }
.stagger-children > *:nth-child(5) { animation-delay: 200ms; }
.stagger-children > *:nth-child(6) { animation-delay: 250ms; }
.stagger-children > *:nth-child(7) { animation-delay: 300ms; }
.stagger-children > *:nth-child(8) { animation-delay: 350ms; }
/* ========================================
Skeleton Loading
======================================== */
.skeleton {
@apply relative overflow-hidden bg-dark-700 rounded;
}
.skeleton::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.05),
transparent
);
animation: skeleton-pulse 1.5s infinite;
}
@keyframes skeleton-pulse {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
/* ========================================
Focus States (Accessibility)
======================================== */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-neon-500 focus:ring-offset-2 focus:ring-offset-dark-900;
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
} }
} }

View File

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

View File

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

View File

@@ -1,113 +1,251 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { Button } from '@/components/ui' import { NeonButton, GradientButton, FeatureCard } from '@/components/ui'
import { Gamepad2, Users, Trophy, Sparkles } from 'lucide-react' import { Gamepad2, Users, Trophy, Sparkles, Zap, Target, Crown, ArrowRight } from 'lucide-react'
export function HomePage() { export function HomePage() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated) const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
return ( return (
<div className="max-w-4xl mx-auto text-center"> <div className="-mt-8 relative">
{/* Hero */} {/* Global animated background - covers entire page */}
<div className="py-12"> <div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="flex justify-center mb-6"> {/* Gradient orbs */}
<Gamepad2 className="w-20 h-20 text-primary-500" /> <div className="absolute top-[10%] left-[10%] w-[500px] h-[500px] bg-neon-500/20 rounded-full blur-[120px] animate-float" />
<div className="absolute top-[40%] right-[10%] w-[600px] h-[600px] bg-accent-500/20 rounded-full blur-[120px] animate-float" style={{ animationDelay: '-3s' }} />
<div className="absolute top-[60%] left-[30%] w-[700px] h-[700px] bg-pink-500/10 rounded-full blur-[150px]" />
<div className="absolute bottom-[10%] right-[30%] w-[400px] h-[400px] bg-neon-500/15 rounded-full blur-[100px] animate-float" style={{ animationDelay: '-1.5s' }} />
<div className="absolute bottom-[30%] left-[5%] w-[450px] h-[450px] bg-accent-500/15 rounded-full blur-[100px] animate-float" style={{ animationDelay: '-4.5s' }} />
</div> </div>
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
Игровой Марафон {/* Hero Section */}
<section className="relative min-h-[80vh] flex items-center justify-center overflow-hidden">
{/* 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_20px_rgba(34,211,238,0.4)]" />
<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> </h1>
<p className="text-xl text-gray-400 mb-8 max-w-2xl mx-auto">
Соревнуйтесь с друзьями в игровых челленджах. Крутите колесо, выполняйте задания, зарабатывайте очки и станьте чемпионом! {/* 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> </p>
<div className="flex gap-4 justify-center"> {/* CTA Buttons */}
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
{isAuthenticated ? ( {isAuthenticated ? (
<Link to="/marathons"> <Link to="/marathons">
<Button size="lg">К марафонам</Button> <GradientButton size="lg" icon={<ArrowRight className="w-5 h-5" />}>
К марафонам
</GradientButton>
</Link> </Link>
) : ( ) : (
<> <>
<Link to="/register"> <Link to="/register">
<Button size="lg">Начать</Button> <GradientButton size="lg" icon={<Zap className="w-5 h-5" />}>
Начать играть
</GradientButton>
</Link> </Link>
<Link to="/login"> <Link to="/login">
<Button size="lg" variant="secondary">Войти</Button> <NeonButton size="lg" variant="outline" color="neon">
Войти
</NeonButton>
</Link> </Link>
</> </>
)} )}
</div> </div>
</div> </div>
{/* Features */} {/* Scroll indicator */}
<div className="grid md:grid-cols-3 gap-8 py-12"> <div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce">
<div className="card text-center"> <div className="w-6 h-10 border-2 border-gray-600 rounded-full flex justify-center pt-2">
<div className="flex justify-center mb-4"> <div className="w-1 h-2 bg-neon-500 rounded-full animate-pulse" />
<Sparkles className="w-12 h-12 text-yellow-500" />
</div> </div>
<h3 className="text-xl font-bold text-white mb-2">Случайные челленджи</h3> </div>
<p className="text-gray-400"> </section>
Крутите колесо, чтобы получить случайную игру и задание. Проверьте свои навыки неожиданным способом!
{/* 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> </p>
</div> </div>
<div className="card text-center"> <div className="grid md:grid-cols-3 gap-6 stagger-children">
<div className="flex justify-center mb-4"> <FeatureCard
<Users className="w-12 h-12 text-green-500" /> icon={<Sparkles className="w-7 h-7" />}
</div> title="Случайные челленджи"
<h3 className="text-xl font-bold text-white mb-2">Играйте с друзьями</h3> description="Крутите колесо и получайте уникальные задания. ИИ генерирует челленджи специально под ваши игры."
<p className="text-gray-400"> color="neon"
Создавайте приватные марафоны и приглашайте друзей. Каждый добавляет свои любимые игры. />
</p> <FeatureCard
</div> icon={<Users className="w-7 h-7" />}
title="Играйте с друзьями"
<div className="card text-center"> description="Создавайте приватные марафоны. Каждый добавляет свои игры, все соревнуются на равных."
<div className="flex justify-center mb-4"> color="purple"
<Trophy className="w-12 h-12 text-primary-500" /> />
</div> <FeatureCard
<h3 className="text-xl font-bold text-white mb-2">Соревнуйтесь за очки</h3> icon={<Trophy className="w-7 h-7" />}
<p className="text-gray-400"> title="Зарабатывайте очки"
Выполняйте задания, чтобы зарабатывать очки. Собирайте серии для бонусных множителей! description="Выполняйте задания, собирайте серии побед. Бонусные множители за стрики!"
</p> color="pink"
/>
</div> </div>
</div> </div>
</section>
{/* How it works */} {/* How it works */}
<div className="py-12"> <section className="py-24 relative">
<h2 className="text-2xl font-bold text-white mb-8">Как это работает</h2> <div className="max-w-6xl mx-auto px-4 relative z-10">
<div className="grid md:grid-cols-4 gap-6 text-left"> <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="relative">
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">1</div> {/* Connection line */}
<div className="relative z-10 pt-6"> <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" />
<h4 className="font-bold text-white mb-2">Создайте марафон</h4>
<p className="text-gray-400 text-sm">Начните новый марафон и пригласите друзей по уникальному коду</p> <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_20px_rgba(34,211,238,0.25)]' : ''}
${item.color === 'accent' ? 'border-accent-500/50 group-hover:border-accent-500 group-hover:shadow-[0_0_20px_rgba(139,92,246,0.25)]' : ''}
${item.color === 'pink' ? 'border-pink-500/50 group-hover:border-pink-500 group-hover:shadow-[0_0_20px_rgba(244,114,182,0.25)]' : ''}
`}
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> </div>
<div className="relative"> <h4 className="text-lg font-semibold text-white mb-2">
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">2</div> {item.title}
<div className="relative z-10 pt-6"> </h4>
<h4 className="font-bold text-white mb-2">Добавьте игры</h4> <p className="text-gray-400 text-sm">
<p className="text-gray-400 text-sm">Все добавляют игры, в которые хотят играть. ИИ генерирует задания</p> {item.desc}
</p>
</div>
))}
</div> </div>
</div> </div>
</div>
</section>
<div className="relative"> {/* CTA Section */}
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">3</div> <section className="py-24 relative">
<div className="relative z-10 pt-6"> <div className="max-w-4xl mx-auto px-4 text-center">
<h4 className="font-bold text-white mb-2">Крутите и играйте</h4> <div className="glass-neon rounded-2xl p-12 relative overflow-hidden">
<p className="text-gray-400 text-sm">Крутите колесо, получите задание, выполните его и отправьте доказательство</p> {/* Background glow */}
</div> <div className="absolute inset-0 bg-gradient-to-r from-neon-500/5 via-accent-500/5 to-pink-500/5" />
</div>
<div className="relative"> <div className="relative z-10">
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">4</div> <h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
<div className="relative z-10 pt-6"> Готовы к соревнованиям?
<h4 className="font-bold text-white mb-2">Победите!</h4> </h2>
<p className="text-gray-400 text-sm">Зарабатывайте очки, поднимайтесь в таблице лидеров, станьте чемпионом!</p> <p className="text-gray-300 mb-8 max-w-xl mx-auto">
</div> Создавайте марафоны, приглашайте друзей и соревнуйтесь в игровых челленджах
</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> </div>
</div> </div>
</section>
</div> </div>
) )
} }

View File

@@ -3,8 +3,8 @@ import { useParams, useNavigate, Link } from 'react-router-dom'
import { marathonsApi } from '@/api' import { marathonsApi } from '@/api'
import type { MarathonPublicInfo } from '@/types' import type { MarathonPublicInfo } from '@/types'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { Button, Card, CardContent, CardHeader, CardTitle } from '@/components/ui' import { NeonButton, GlassCard } from '@/components/ui'
import { Users, Loader2, Trophy, UserPlus, LogIn } from 'lucide-react' import { Users, Loader2, Trophy, UserPlus, LogIn, Gamepad2, AlertCircle, Sparkles, Crown } from 'lucide-react'
export function InvitePage() { export function InvitePage() {
const { code } = useParams<{ code: string }>() const { code } = useParams<{ code: string }>()
@@ -63,8 +63,9 @@ export function InvitePage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex justify-center py-12"> <div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" /> <Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка приглашения...</p>
</div> </div>
) )
} }
@@ -72,97 +73,154 @@ export function InvitePage() {
if (error || !marathon) { if (error || !marathon) {
return ( return (
<div className="max-w-md mx-auto"> <div className="max-w-md mx-auto">
<Card> <GlassCard className="text-center py-12">
<CardContent className="text-center py-8"> <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">
<div className="text-red-400 mb-4">{error || 'Марафон не найден'}</div> <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"> <Link to="/marathons">
<Button variant="secondary">К списку марафонов</Button> <NeonButton variant="outline">К списку марафонов</NeonButton>
</Link> </Link>
</CardContent> </GlassCard>
</Card>
</div> </div>
) )
} }
const statusText = { const getStatusConfig = (status: string) => {
preparing: 'Подготовка', switch (status) {
active: 'Активен', case 'preparing':
finished: 'Завершён', return {
}[marathon.status] 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 ( return (
<div className="max-w-md mx-auto"> <div className="min-h-[70vh] flex items-center justify-center px-4">
<Card> {/* Background effects */}
<CardHeader className="text-center"> <div className="fixed inset-0 overflow-hidden pointer-events-none">
<CardTitle className="flex items-center justify-center gap-2"> <div className="absolute top-1/4 -left-32 w-96 h-96 bg-neon-500/10 rounded-full blur-[100px]" />
<Trophy className="w-6 h-6 text-primary-500" /> <div className="absolute bottom-1/4 -right-32 w-96 h-96 bg-accent-500/10 rounded-full blur-[100px]" />
Приглашение в марафон
</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>
<p className="text-gray-500 text-xs mt-2">
Организатор: {marathon.creator_nickname}
</p>
</div> </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>
<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' ? ( {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> </div>
) : isAuthenticated ? ( ) : isAuthenticated ? (
/* Authenticated - show join button */ <NeonButton
<Button
className="w-full" className="w-full"
size="lg"
onClick={handleJoin} onClick={handleJoin}
isLoading={isJoining} isLoading={isJoining}
icon={<Sparkles className="w-5 h-5" />}
> >
<UserPlus className="w-4 h-4 mr-2" /> Присоединиться
Присоединиться к марафону </NeonButton>
</Button>
) : ( ) : (
/* Not authenticated - show login/register options */ <div className="space-y-4">
<div className="space-y-3">
<p className="text-center text-gray-400 text-sm"> <p className="text-center text-gray-400 text-sm">
Чтобы присоединиться, войдите или зарегистрируйтесь Чтобы присоединиться, войдите или зарегистрируйтесь
</p> </p>
<Button <NeonButton
className="w-full" className="w-full"
size="lg"
onClick={() => handleAuthRedirect('/login')} onClick={() => handleAuthRedirect('/login')}
icon={<LogIn className="w-5 h-5" />}
> >
<LogIn className="w-4 h-4 mr-2" />
Войти Войти
</Button> </NeonButton>
<Button <NeonButton
variant="secondary" variant="outline"
className="w-full" className="w-full"
onClick={() => handleAuthRedirect('/register')} onClick={() => handleAuthRedirect('/register')}
icon={<UserPlus className="w-5 h-5" />}
> >
<UserPlus className="w-4 h-4 mr-2" />
Зарегистрироваться Зарегистрироваться
</Button> </NeonButton>
</div> </div>
)} )}
</CardContent> </GlassCard>
</Card>
{/* 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> </div>
) )
} }

View File

@@ -2,9 +2,9 @@ import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { marathonsApi } from '@/api' import { marathonsApi } from '@/api'
import type { LeaderboardEntry } from '@/types' import type { LeaderboardEntry } from '@/types'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui' import { GlassCard } from '@/components/ui'
import { useAuthStore } from '@/store/auth' 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() { export function LeaderboardPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
@@ -28,92 +28,257 @@ export function LeaderboardPage() {
} }
} }
const getRankIcon = (rank: number) => { const getRankConfig = (rank: number) => {
switch (rank) { switch (rank) {
case 1: 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: 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: 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: 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) { if (isLoading) {
return ( return (
<div className="flex justify-center py-12"> <div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" /> <Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка рейтинга...</p>
</div> </div>
) )
} }
return ( 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"> <div className="flex items-center gap-4 mb-8">
<Link to={`/marathons/${id}`} className="text-gray-400 hover:text-white"> <Link
<ArrowLeft className="w-6 h-6" /> 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> </Link>
<div>
<h1 className="text-2xl font-bold text-white">Таблица лидеров</h1> <h1 className="text-2xl font-bold text-white">Таблица лидеров</h1>
<p className="text-gray-400 text-sm">Рейтинг участников марафона</p>
</div>
</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 ? ( {leaderboard.length === 0 ? (
<p className="text-center text-gray-400 py-8">Пока нет участников</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">
<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>
<Link to={`/users/${topThree[1].user.id}`} className="glass rounded-xl p-4 text-center w-28 hover:border-neon-500/30 transition-colors border border-transparent">
<Medal className="w-6 h-6 text-gray-300 mx-auto mb-2" />
<p className="text-sm font-medium text-white truncate hover:text-neon-400 transition-colors">{topThree[1].user.nickname}</p>
<p className="text-xs text-gray-400">{topThree[1].total_points} очков</p>
</Link>
</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>
<Link to={`/users/${topThree[0].user.id}`} className="glass-neon rounded-xl p-4 text-center w-32 hover:shadow-[0_0_20px_rgba(34,211,238,0.3)] transition-shadow">
<Star className="w-6 h-6 text-yellow-400 mx-auto mb-2" />
<p className="font-semibold text-white truncate hover:text-neon-400 transition-colors">{topThree[0].user.nickname}</p>
<p className="text-sm text-neon-400 font-bold">{topThree[0].total_points} очков</p>
</Link>
</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>
<Link to={`/users/${topThree[2].user.id}`} className="glass rounded-xl p-4 text-center w-28 hover:border-neon-500/30 transition-colors border border-transparent">
<Award className="w-6 h-6 text-amber-600 mx-auto mb-2" />
<p className="text-sm font-medium text-white truncate hover:text-neon-400 transition-colors">{topThree[2].user.nickname}</p>
<p className="text-xs text-gray-400">{topThree[2].total_points} очков</p>
</Link>
</div>
</div>
</div>
)}
{/* 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"> <div className="space-y-2">
{leaderboard.map((entry) => ( {leaderboard.map((entry, index) => {
const isCurrentUser = entry.user.id === user?.id
const rankConfig = getRankConfig(entry.rank)
return (
<div <div
key={entry.user.id} key={entry.user.id}
className={`flex items-center gap-4 p-4 rounded-lg ${ className={`
entry.user.id === user?.id relative flex items-center gap-4 p-4 rounded-xl
? 'bg-primary-500/20 border border-primary-500/50' transition-all duration-300 group
: 'bg-gray-900' ${isCurrentUser
}`} ? 'bg-neon-500/10 border border-neon-500/30 shadow-[0_0_14px_rgba(34,211,238,0.08)]'
: `${rankConfig.bg} border ${rankConfig.border} hover:border-neon-500/20`
}
`}
style={{ animationDelay: `${index * 50}ms` }}
> >
<div className="flex items-center justify-center w-8"> {/* Gradient overlay for top 3 */}
{getRankIcon(entry.rank)} {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> </div>
<div className="flex-1"> {/* User info */}
<div className="font-medium text-white"> <div className="relative flex-1 min-w-0">
<div className="flex items-center gap-2">
<Link
to={`/users/${entry.user.id}`}
className={`font-semibold truncate hover:text-neon-400 transition-colors ${isCurrentUser ? 'text-neon-400' : 'text-white'}`}
>
{entry.user.nickname} {entry.user.nickname}
{entry.user.id === user?.id && ( </Link>
<span className="ml-2 text-xs text-primary-400">(Вы)</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>
<div className="text-sm text-gray-400"> <div className="flex items-center gap-3 text-sm text-gray-400">
{entry.completed_count} выполнено, {entry.dropped_count} пропущено <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>
</div> </div>
{/* Streak */}
{entry.current_streak > 0 && ( {entry.current_streak > 0 && (
<div className="flex items-center gap-1 text-yellow-500"> <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" /> <Flame className="w-4 h-4 text-orange-400" />
<span className="text-sm">{entry.current_streak}</span> <span className="text-sm font-semibold text-orange-400">{entry.current_streak}</span>
</div> </div>
)} )}
<div className="text-right"> {/* Points */}
<div className="text-xl font-bold text-primary-400"> <div className="relative text-right">
<div className={`text-xl font-bold ${isCurrentUser ? 'text-neon-400' : 'text-white'}`}>
{entry.total_points} {entry.total_points}
</div> </div>
<div className="text-xs text-gray-500">очков</div> <div className="text-xs text-gray-500">очков</div>
</div> </div>
</div> </div>
))} )
})}
</div> </div>
</GlassCard>
</>
)} )}
</CardContent>
</Card>
</div> </div>
) )
} }

View File

@@ -2,13 +2,13 @@ import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, useNavigate, Link } from 'react-router-dom'
import { marathonsApi, gamesApi } from '@/api' import { marathonsApi, gamesApi } from '@/api'
import type { Marathon, Game, Challenge, ChallengePreview } from '@/types' 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 { useAuthStore } from '@/store/auth'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm' import { useConfirm } from '@/store/confirm'
import { import {
Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye, 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' } from 'lucide-react'
export function LobbyPage() { export function LobbyPage() {
@@ -39,6 +39,8 @@ export function LobbyPage() {
const [previewChallenges, setPreviewChallenges] = useState<ChallengePreview[] | null>(null) const [previewChallenges, setPreviewChallenges] = useState<ChallengePreview[] | null>(null)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [editingIndex, setEditingIndex] = useState<number | null>(null) const [editingIndex, setEditingIndex] = useState<number | null>(null)
const [showGenerateSelection, setShowGenerateSelection] = useState(false)
const [selectedGamesForGeneration, setSelectedGamesForGeneration] = useState<number[]>([])
// View existing challenges // View existing challenges
const [expandedGameId, setExpandedGameId] = useState<number | null>(null) const [expandedGameId, setExpandedGameId] = useState<number | null>(null)
@@ -72,17 +74,14 @@ export function LobbyPage() {
const marathonData = await marathonsApi.get(parseInt(id)) const marathonData = await marathonsApi.get(parseInt(id))
setMarathon(marathonData) setMarathon(marathonData)
// Load games - organizers see all, participants see approved + own
const gamesData = await gamesApi.list(parseInt(id)) const gamesData = await gamesApi.list(parseInt(id))
setGames(gamesData) setGames(gamesData)
// If organizer, load pending games separately
if (marathonData.my_participation?.role === 'organizer' || user?.role === 'admin') { if (marathonData.my_participation?.role === 'organizer' || user?.role === 'admin') {
try { try {
const pending = await gamesApi.listPending(parseInt(id)) const pending = await gamesApi.listPending(parseInt(id))
setPendingGames(pending) setPendingGames(pending)
} catch { } catch {
// If not authorized, just ignore
setPendingGames([]) setPendingGames([])
} }
} }
@@ -175,7 +174,6 @@ export function LobbyPage() {
setExpandedGameId(gameId) setExpandedGameId(gameId)
// Load challenges if we haven't loaded them yet
if (!gameChallenges[gameId]) { if (!gameChallenges[gameId]) {
setLoadingChallenges(gameId) setLoadingChallenges(gameId)
try { try {
@@ -183,7 +181,6 @@ export function LobbyPage() {
setGameChallenges(prev => ({ ...prev, [gameId]: challenges })) setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
} catch (error) { } catch (error) {
console.error('Failed to load challenges:', error) console.error('Failed to load challenges:', error)
// Set empty array to prevent repeated attempts
setGameChallenges(prev => ({ ...prev, [gameId]: [] })) setGameChallenges(prev => ({ ...prev, [gameId]: [] }))
} finally { } finally {
setLoadingChallenges(null) setLoadingChallenges(null)
@@ -210,7 +207,6 @@ export function LobbyPage() {
proof_hint: newChallenge.proof_hint.trim() || undefined, proof_hint: newChallenge.proof_hint.trim() || undefined,
}) })
toast.success('Задание добавлено') toast.success('Задание добавлено')
// Reset form
setNewChallenge({ setNewChallenge({
title: '', title: '',
description: '', description: '',
@@ -222,7 +218,6 @@ export function LobbyPage() {
proof_hint: '', proof_hint: '',
}) })
setAddingChallengeToGameId(null) setAddingChallengeToGameId(null)
// Refresh challenges
const challenges = await gamesApi.getChallenges(gameId) const challenges = await gamesApi.getChallenges(gameId)
setGameChallenges(prev => ({ ...prev, [gameId]: challenges })) setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
await loadData() await loadData()
@@ -246,10 +241,9 @@ export function LobbyPage() {
try { try {
await gamesApi.deleteChallenge(challengeId) await gamesApi.deleteChallenge(challengeId)
// Refresh challenges for this game
const challenges = await gamesApi.getChallenges(gameId) const challenges = await gamesApi.getChallenges(gameId)
setGameChallenges(prev => ({ ...prev, [gameId]: challenges })) setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
await loadData() // Refresh game counts await loadData()
} catch (error) { } catch (error) {
console.error('Failed to delete challenge:', error) console.error('Failed to delete challenge:', error)
} }
@@ -261,11 +255,14 @@ export function LobbyPage() {
setIsGenerating(true) setIsGenerating(true)
setGenerateMessage(null) setGenerateMessage(null)
try { try {
const result = await gamesApi.previewChallenges(parseInt(id)) // Pass selected games if any, otherwise generate for all games without challenges
const gameIds = selectedGamesForGeneration.length > 0 ? selectedGamesForGeneration : undefined
const result = await gamesApi.previewChallenges(parseInt(id), gameIds)
if (result.challenges.length === 0) { if (result.challenges.length === 0) {
setGenerateMessage('Все игры уже имеют задания') setGenerateMessage('Нет игр для генерации заданий')
} else { } else {
setPreviewChallenges(result.challenges) setPreviewChallenges(result.challenges)
setShowGenerateSelection(false)
} }
} catch (error) { } catch (error) {
console.error('Failed to generate challenges:', error) console.error('Failed to generate challenges:', error)
@@ -275,6 +272,22 @@ export function LobbyPage() {
} }
} }
const toggleGameSelection = (gameId: number) => {
setSelectedGamesForGeneration(prev =>
prev.includes(gameId)
? prev.filter(id => id !== gameId)
: [...prev, gameId]
)
}
const selectAllGamesForGeneration = () => {
setSelectedGamesForGeneration(approvedGames.map(g => g.id))
}
const clearGameSelection = () => {
setSelectedGamesForGeneration([])
}
const handleSaveChallenges = async () => { const handleSaveChallenges = async () => {
if (!id || !previewChallenges) return if (!id || !previewChallenges) return
@@ -283,7 +296,7 @@ export function LobbyPage() {
const result = await gamesApi.saveChallenges(parseInt(id), previewChallenges) const result = await gamesApi.saveChallenges(parseInt(id), previewChallenges)
setGenerateMessage(result.message) setGenerateMessage(result.message)
setPreviewChallenges(null) setPreviewChallenges(null)
setGameChallenges({}) // Clear cache to reload setGameChallenges({})
await loadData() await loadData()
} catch (error) { } catch (error) {
console.error('Failed to save challenges:', error) console.error('Failed to save challenges:', error)
@@ -337,8 +350,9 @@ export function LobbyPage() {
if (isLoading || !marathon) { if (isLoading || !marathon) {
return ( return (
<div className="flex justify-center py-12"> <div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" /> <Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка лобби...</p>
</div> </div>
) )
} }
@@ -351,21 +365,21 @@ export function LobbyPage() {
switch (status) { switch (status) {
case 'approved': case 'approved':
return ( 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" /> <CheckCircle className="w-3 h-3" />
Одобрено Одобрено
</span> </span>
) )
case 'pending': case 'pending':
return ( 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" /> <Clock className="w-3 h-3" />
На модерации На модерации
</span> </span>
) )
case 'rejected': case 'rejected':
return ( 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" /> <XCircle className="w-3 h-3" />
Отклонено Отклонено
</span> </span>
@@ -376,11 +390,11 @@ export function LobbyPage() {
} }
const renderGameCard = (game: Game, showModeration = false) => ( 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 */} {/* Game header */}
<div <div
className={`flex items-center justify-between p-4 ${ 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)} onClick={() => game.status === 'approved' && handleToggleGameChallenges(game.id)}
> >
@@ -394,14 +408,22 @@ export function LobbyPage() {
)} )}
</span> </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-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <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)} {getStatusBadge(game.status)}
</div> </div>
<div className="text-sm text-gray-400 flex items-center gap-3 flex-wrap"> <div className="text-sm text-gray-400 flex items-center gap-3 flex-wrap">
{game.genre && <span>{game.genre}</span>} {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 && ( {game.proposed_by && (
<span className="flex items-center gap-1 text-gray-500"> <span className="flex items-center gap-1 text-gray-500">
<User className="w-3 h-3" /> <User className="w-3 h-3" />
@@ -414,49 +436,43 @@ export function LobbyPage() {
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}> <div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
{showModeration && game.status === 'pending' && ( {showModeration && game.status === 'pending' && (
<> <>
<Button <button
variant="ghost"
size="sm"
onClick={() => handleApproveGame(game.id)} onClick={() => handleApproveGame(game.id)}
disabled={moderatingGameId === 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 ? ( {moderatingGameId === game.id ? (
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
) : ( ) : (
<CheckCircle className="w-4 h-4" /> <CheckCircle className="w-4 h-4" />
)} )}
</Button> </button>
<Button <button
variant="ghost"
size="sm"
onClick={() => handleRejectGame(game.id)} onClick={() => handleRejectGame(game.id)}
disabled={moderatingGameId === 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" /> <XCircle className="w-4 h-4" />
</Button> </button>
</> </>
)} )}
{(isOrganizer || game.proposed_by?.id === user?.id) && ( {(isOrganizer || game.proposed_by?.id === user?.id) && (
<Button <button
variant="ghost"
size="sm"
onClick={() => handleDeleteGame(game.id)} 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" /> <Trash2 className="w-4 h-4" />
</Button> </button>
)} )}
</div> </div>
</div> </div>
{/* Expanded challenges list */} {/* Expanded challenges list */}
{expandedGameId === game.id && ( {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 ? ( {loadingChallenges === game.id ? (
<div className="flex justify-center py-4"> <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> </div>
) : ( ) : (
<> <>
@@ -464,24 +480,24 @@ export function LobbyPage() {
gameChallenges[game.id].map((challenge) => ( gameChallenges[game.id].map((challenge) => (
<div <div
key={challenge.id} 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-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1 flex-wrap">
<span className={`text-xs px-2 py-0.5 rounded ${ <span className={`text-xs px-2 py-0.5 rounded-lg border ${
challenge.difficulty === 'easy' ? 'bg-green-900/50 text-green-400' : challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
challenge.difficulty === 'medium' ? 'bg-yellow-900/50 text-yellow-400' : challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
'bg-red-900/50 text-red-400' 'bg-red-500/20 text-red-400 border-red-500/30'
}`}> }`}>
{challenge.difficulty === 'easy' ? 'Легко' : {challenge.difficulty === 'easy' ? 'Легко' :
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'} challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
</span> </span>
<span className="text-xs text-primary-400 font-medium"> <span className="text-xs text-neon-400 font-semibold">
+{challenge.points} +{challenge.points}
</span> </span>
{challenge.is_generated && ( {challenge.is_generated && (
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500 flex items-center gap-1">
<Sparkles className="w-3 h-3 inline" /> ИИ <Sparkles className="w-3 h-3" /> ИИ
</span> </span>
)} )}
</div> </div>
@@ -489,19 +505,17 @@ export function LobbyPage() {
<p className="text-xs text-gray-400 mt-1">{challenge.description}</p> <p className="text-xs text-gray-400 mt-1">{challenge.description}</p>
</div> </div>
{isOrganizer && ( {isOrganizer && (
<Button <button
variant="ghost"
size="sm"
onClick={() => handleDeleteChallenge(challenge.id, game.id)} 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" /> <Trash2 className="w-3 h-3" />
</Button> </button>
)} )}
</div> </div>
)) ))
) : ( ) : (
<p className="text-center text-gray-500 py-2 text-sm"> <p className="text-center text-gray-500 py-4 text-sm">
Нет заданий Нет заданий
</p> </p>
)} )}
@@ -509,8 +523,11 @@ export function LobbyPage() {
{/* Add challenge form */} {/* Add challenge form */}
{isOrganizer && game.status === 'approved' && ( {isOrganizer && game.status === 'approved' && (
addingChallengeToGameId === game.id ? ( addingChallengeToGameId === game.id ? (
<div className="mt-4 p-4 bg-gray-800 rounded-lg space-y-3 border border-gray-700"> <div className="mt-4 p-4 glass rounded-xl space-y-3 border border-neon-500/20">
<h4 className="font-medium text-white text-sm">Новое задание</h4> <h4 className="font-semibold text-white text-sm flex items-center gap-2">
<Plus className="w-4 h-4 text-neon-400" />
Новое задание
</h4>
<Input <Input
placeholder="Название задания" placeholder="Название задания"
value={newChallenge.title} value={newChallenge.title}
@@ -520,7 +537,7 @@ export function LobbyPage() {
placeholder="Описание (что нужно сделать)" placeholder="Описание (что нужно сделать)"
value={newChallenge.description} value={newChallenge.description}
onChange={(e) => setNewChallenge(prev => ({ ...prev, description: e.target.value }))} 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} rows={2}
/> />
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@@ -529,7 +546,7 @@ export function LobbyPage() {
<select <select
value={newChallenge.type} value={newChallenge.type}
onChange={(e) => setNewChallenge(prev => ({ ...prev, type: e.target.value }))} 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="completion">Прохождение</option>
<option value="no_death">Без смертей</option> <option value="no_death">Без смертей</option>
@@ -544,7 +561,7 @@ export function LobbyPage() {
<select <select
value={newChallenge.difficulty} value={newChallenge.difficulty}
onChange={(e) => setNewChallenge(prev => ({ ...prev, difficulty: e.target.value }))} 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="easy">Легко (20-40 очков)</option>
<option value="medium">Средне (45-75 очков)</option> <option value="medium">Средне (45-75 очков)</option>
@@ -575,11 +592,11 @@ export function LobbyPage() {
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<label className="text-xs text-gray-400 mb-1 block">Тип доказательства</label> <label className="text-xs text-gray-400 mb-1 block">Тип пруфа</label>
<select <select
value={newChallenge.proof_type} value={newChallenge.proof_type}
onChange={(e) => setNewChallenge(prev => ({ ...prev, proof_type: e.target.value }))} 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="screenshot">Скриншот</option>
<option value="video">Видео</option> <option value="video">Видео</option>
@@ -589,44 +606,42 @@ export function LobbyPage() {
<div> <div>
<label className="text-xs text-gray-400 mb-1 block">Подсказка</label> <label className="text-xs text-gray-400 mb-1 block">Подсказка</label>
<Input <Input
placeholder="Что должно быть на пруфе" placeholder="Что на пруфе"
value={newChallenge.proof_hint} value={newChallenge.proof_hint}
onChange={(e) => setNewChallenge(prev => ({ ...prev, proof_hint: e.target.value }))} onChange={(e) => setNewChallenge(prev => ({ ...prev, proof_hint: e.target.value }))}
/> />
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <NeonButton
size="sm" size="sm"
onClick={() => handleCreateChallenge(game.id)} onClick={() => handleCreateChallenge(game.id)}
isLoading={isCreatingChallenge} isLoading={isCreatingChallenge}
disabled={!newChallenge.title || !newChallenge.description} disabled={!newChallenge.title || !newChallenge.description}
icon={<Plus className="w-4 h-4" />}
> >
<Plus className="w-4 h-4 mr-1" />
Добавить Добавить
</Button> </NeonButton>
<Button <NeonButton
variant="ghost" variant="outline"
size="sm" size="sm"
onClick={() => setAddingChallengeToGameId(null)} onClick={() => setAddingChallengeToGameId(null)}
> >
Отмена Отмена
</Button> </NeonButton>
</div> </div>
</div> </div>
) : ( ) : (
<Button <button
variant="ghost"
size="sm"
onClick={() => { onClick={() => {
setAddingChallengeToGameId(game.id) setAddingChallengeToGameId(game.id)
setExpandedGameId(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 +654,18 @@ export function LobbyPage() {
return ( return (
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
{/* Back button */} {/* Back button */}
<Link to={`/marathons/${id}`} className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors"> <Link
<ArrowLeft className="w-4 h-4" /> 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> </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> <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"> <p className="text-gray-400">
{isOrganizer {isOrganizer
? 'Настройка - Добавьте игры и сгенерируйте задания' ? 'Настройка - Добавьте игры и сгенерируйте задания'
@@ -655,130 +674,216 @@ export function LobbyPage() {
</div> </div>
{isOrganizer && ( {isOrganizer && (
<Button onClick={handleStartMarathon} isLoading={isStarting} disabled={approvedGames.length === 0}> <NeonButton
<Play className="w-4 h-4 mr-2" /> onClick={handleStartMarathon}
isLoading={isStarting}
disabled={approvedGames.length === 0}
icon={<Play className="w-4 h-4" />}
>
Запустить марафон Запустить марафон
</Button> </NeonButton>
)} )}
</div> </div>
{/* Stats - только для организаторов */} {/* Stats */}
{isOrganizer && ( {isOrganizer && (
<div className="grid grid-cols-2 gap-4 mb-8"> <div className="grid grid-cols-2 gap-4 mb-8">
<Card> <StatsCard
<CardContent className="text-center py-4"> label="Игр одобрено"
<div className="text-2xl font-bold text-white">{approvedGames.length}</div> value={approvedGames.length}
<div className="text-sm text-gray-400 flex items-center justify-center gap-1"> icon={<Gamepad2 className="w-6 h-6" />}
<Gamepad2 className="w-4 h-4" /> color="neon"
Игр одобрено />
</div> <StatsCard
</CardContent> label="Заданий"
</Card> value={totalChallenges}
icon={<Sparkles className="w-6 h-6" />}
<Card> color="purple"
<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>
</div> </div>
)} )}
{/* Pending games for moderation (organizers only) */} {/* Pending games for moderation */}
{isOrganizer && pendingGames.length > 0 && ( {isOrganizer && pendingGames.length > 0 && (
<Card className="mb-8 border-yellow-900/50"> <GlassCard className="mb-8 border-yellow-500/30">
<CardHeader> <div className="flex items-center gap-3 mb-4">
<CardTitle className="flex items-center gap-2 text-yellow-400"> <div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center">
<Clock className="w-5 h-5" /> <Clock className="w-5 h-5 text-yellow-400" />
На модерации ({pendingGames.length}) </div>
</CardTitle> <div>
</CardHeader> <h3 className="font-semibold text-yellow-400">На модерации</h3>
<CardContent> <p className="text-sm text-gray-400">{pendingGames.length} игр ожидают</p>
</div>
</div>
<div className="space-y-3"> <div className="space-y-3">
{pendingGames.map((game) => renderGameCard(game, true))} {pendingGames.map((game) => renderGameCard(game, true))}
</div> </div>
</CardContent> </GlassCard>
</Card>
)} )}
{/* Generate challenges button */} {/* Generate challenges */}
{isOrganizer && approvedGames.length > 0 && !previewChallenges && ( {isOrganizer && approvedGames.length > 0 && !previewChallenges && (
<Card className="mb-8"> <GlassCard className="mb-8">
<CardContent> <div className="flex items-center justify-between gap-4 flex-wrap mb-4">
<div className="flex items-center justify-between"> <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> <div>
<h3 className="font-medium text-white">Генерация заданий</h3> <h3 className="font-semibold text-white">Генерация заданий</h3>
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
Используйте ИИ для генерации заданий для одобренных игр без заданий {showGenerateSelection
? `Выбрано: ${selectedGamesForGeneration.length} из ${approvedGames.length}`
: 'Выберите игры для генерации'}
</p> </p>
</div> </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>
)}
{/* Challenge preview with editing */}
{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>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={handleCancelPreview} variant="ghost" size="sm"> {showGenerateSelection ? (
<X className="w-4 h-4 mr-1" /> <>
<NeonButton
onClick={() => {
setShowGenerateSelection(false)
clearGameSelection()
}}
variant="secondary"
size="sm"
>
Отмена Отмена
</Button> </NeonButton>
<Button onClick={handleSaveChallenges} isLoading={isSaving} size="sm"> <NeonButton
<Save className="w-4 h-4 mr-1" /> onClick={handleGenerateChallenges}
Сохранить все isLoading={isGenerating}
</Button> color="purple"
size="sm"
icon={<Sparkles className="w-4 h-4" />}
disabled={selectedGamesForGeneration.length === 0}
>
Сгенерировать ({selectedGamesForGeneration.length})
</NeonButton>
</>
) : (
<NeonButton
onClick={() => setShowGenerateSelection(true)}
variant="outline"
color="purple"
icon={<Sparkles className="w-4 h-4" />}
>
Выбрать игры
</NeonButton>
)}
</div> </div>
</CardHeader> </div>
<CardContent>
<div className="space-y-3 max-h-[60vh] overflow-y-auto"> {/* Game selection */}
{showGenerateSelection && (
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<button
onClick={selectAllGamesForGeneration}
className="text-neon-400 hover:text-neon-300 transition-colors"
>
Выбрать все
</button>
<button
onClick={clearGameSelection}
className="text-gray-400 hover:text-gray-300 transition-colors"
>
Снять выбор
</button>
</div>
<div className="grid gap-2">
{approvedGames.map((game) => {
const isSelected = selectedGamesForGeneration.includes(game.id)
const challengeCount = gameChallenges[game.id]?.length ?? game.challenges_count
return (
<button
key={game.id}
onClick={() => toggleGameSelection(game.id)}
className={`flex items-center gap-3 p-3 rounded-lg border transition-all text-left ${
isSelected
? 'bg-accent-500/20 border-accent-500/50'
: 'bg-dark-700/50 border-dark-600 hover:border-dark-500'
}`}
>
<div className={`w-5 h-5 rounded flex items-center justify-center border-2 transition-colors ${
isSelected
? 'bg-accent-500 border-accent-500'
: 'border-gray-500'
}`}>
{isSelected && <Check className="w-3 h-3 text-white" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-white font-medium truncate">{game.title}</p>
<p className="text-xs text-gray-400">
{challengeCount > 0 ? `${challengeCount} заданий` : 'Нет заданий'}
</p>
</div>
</button>
)
})}
</div>
</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 */}
{previewChallenges && previewChallenges.length > 0 && (
<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">
<NeonButton onClick={handleCancelPreview} variant="outline" size="sm" icon={<X className="w-4 h-4" />}>
Отмена
</NeonButton>
<NeonButton onClick={handleSaveChallenges} isLoading={isSaving} size="sm" icon={<Save className="w-4 h-4" />}>
Сохранить все
</NeonButton>
</div>
</div>
<div className="space-y-3 max-h-[60vh] overflow-y-auto custom-scrollbar">
{previewChallenges.map((challenge, index) => ( {previewChallenges.map((challenge, index) => (
<div <div
key={index} key={index}
className="p-4 bg-gray-900 rounded-lg border border-gray-800" className="p-4 bg-dark-700/50 rounded-xl border border-dark-600"
> >
{editingIndex === index ? ( {editingIndex === index ? (
// Edit mode
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2"> <span className="text-xs px-2 py-0.5 rounded-lg bg-dark-600 text-gray-400">
<span className="text-xs px-2 py-0.5 rounded bg-gray-800 text-gray-400">
{challenge.game_title} {challenge.game_title}
</span> </span>
</div>
<Input <Input
value={challenge.title} value={challenge.title}
onChange={(e) => handleUpdatePreviewChallenge(index, 'title', e.target.value)} onChange={(e) => handleUpdatePreviewChallenge(index, 'title', e.target.value)}
placeholder="Название" placeholder="Название"
className="bg-gray-800"
/> />
<textarea <textarea
value={challenge.description} value={challenge.description}
onChange={(e) => handleUpdatePreviewChallenge(index, 'description', e.target.value)} onChange={(e) => handleUpdatePreviewChallenge(index, 'description', e.target.value)}
placeholder="Описание" placeholder="Описание"
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm resize-none" className="input w-full resize-none"
rows={2} rows={2}
/> />
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
<select <select
value={challenge.difficulty} value={challenge.difficulty}
onChange={(e) => handleUpdatePreviewChallenge(index, 'difficulty', e.target.value)} 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" className="input"
> >
<option value="easy">Легко</option> <option value="easy">Легко</option>
<option value="medium">Средне</option> <option value="medium">Средне</option>
@@ -789,12 +894,11 @@ export function LobbyPage() {
value={challenge.points} value={challenge.points}
onChange={(e) => handleUpdatePreviewChallenge(index, 'points', parseInt(e.target.value) || 0)} onChange={(e) => handleUpdatePreviewChallenge(index, 'points', parseInt(e.target.value) || 0)}
placeholder="Очки" placeholder="Очки"
className="bg-gray-800"
/> />
<select <select
value={challenge.proof_type} value={challenge.proof_type}
onChange={(e) => handleUpdatePreviewChallenge(index, 'proof_type', e.target.value)} 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" className="input"
> >
<option value="screenshot">Скриншот</option> <option value="screenshot">Скриншот</option>
<option value="video">Видео</option> <option value="video">Видео</option>
@@ -804,42 +908,39 @@ export function LobbyPage() {
<Input <Input
value={challenge.proof_hint || ''} value={challenge.proof_hint || ''}
onChange={(e) => handleUpdatePreviewChallenge(index, 'proof_hint', e.target.value)} onChange={(e) => handleUpdatePreviewChallenge(index, 'proof_hint', e.target.value)}
placeholder="Подсказка для подтверждения" placeholder="Подсказка для пруфа"
className="bg-gray-800"
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<Button size="sm" onClick={() => setEditingIndex(null)}> <NeonButton size="sm" onClick={() => setEditingIndex(null)} icon={<Check className="w-4 h-4" />}>
<Check className="w-4 h-4 mr-1" />
Готово Готово
</Button> </NeonButton>
<Button <NeonButton
variant="ghost" variant="outline"
size="sm" size="sm"
onClick={() => handleRemovePreviewChallenge(index)} onClick={() => handleRemovePreviewChallenge(index)}
className="text-red-400 hover:text-red-300" className="text-red-400 border-red-500/30 hover:bg-red-500/10"
icon={<Trash2 className="w-4 h-4" />}
> >
<Trash2 className="w-4 h-4 mr-1" />
Удалить Удалить
</Button> </NeonButton>
</div> </div>
</div> </div>
) : ( ) : (
// View mode
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-xs px-2 py-0.5 rounded bg-gray-800 text-gray-400"> <span className="text-xs px-2 py-0.5 rounded-lg bg-dark-600 text-gray-400">
{challenge.game_title} {challenge.game_title}
</span> </span>
<span className={`text-xs px-2 py-0.5 rounded ${ <span className={`text-xs px-2 py-0.5 rounded-lg border ${
challenge.difficulty === 'easy' ? 'bg-green-900/50 text-green-400' : challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
challenge.difficulty === 'medium' ? 'bg-yellow-900/50 text-yellow-400' : challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
'bg-red-900/50 text-red-400' 'bg-red-500/20 text-red-400 border-red-500/30'
}`}> }`}>
{challenge.difficulty === 'easy' ? 'Легко' : {challenge.difficulty === 'easy' ? 'Легко' :
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'} challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
</span> </span>
<span className="text-xs text-primary-400 font-medium"> <span className="text-xs text-neon-400 font-semibold">
+{challenge.points} очков +{challenge.points} очков
</span> </span>
</div> </div>
@@ -847,53 +948,55 @@ export function LobbyPage() {
<p className="text-sm text-gray-400">{challenge.description}</p> <p className="text-sm text-gray-400">{challenge.description}</p>
{challenge.proof_hint && ( {challenge.proof_hint && (
<p className="text-xs text-gray-500 mt-2"> <p className="text-xs text-gray-500 mt-2">
Подтверждение: {challenge.proof_hint} Пруф: {challenge.proof_hint}
</p> </p>
)} )}
</div> </div>
<div className="flex gap-1 shrink-0"> <div className="flex gap-1 shrink-0">
<Button <button
variant="ghost"
size="sm"
onClick={() => setEditingIndex(index)} onClick={() => setEditingIndex(index)}
className="text-gray-400 hover:text-white" className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
> >
<Edit2 className="w-4 h-4" /> <Edit2 className="w-4 h-4" />
</Button> </button>
<Button <button
variant="ghost"
size="sm"
onClick={() => handleRemovePreviewChallenge(index)} onClick={() => handleRemovePreviewChallenge(index)}
className="text-red-400 hover:text-red-300" className="p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</Button> </button>
</div> </div>
</div> </div>
)} )}
</div> </div>
))} ))}
</div> </div>
</CardContent> </GlassCard>
</Card>
)} )}
{/* Games list */} {/* Games list */}
<Card> <GlassCard>
<CardHeader className="flex flex-row items-center justify-between"> <div className="flex items-center justify-between mb-6">
<CardTitle>Игры</CardTitle> <div className="flex items-center gap-3">
{/* Показываем кнопку если: all_participants ИЛИ (organizer_only И isOrganizer) */} <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) && ( {(marathon.game_proposal_mode === 'all_participants' || isOrganizer) && (
<Button size="sm" onClick={() => setShowAddGame(!showAddGame)}> <NeonButton
<Plus className="w-4 h-4 mr-1" /> size="sm"
{isOrganizer ? 'Добавить игру' : 'Предложить игру'} onClick={() => setShowAddGame(!showAddGame)}
</Button> icon={<Plus className="w-4 h-4" />}
>
{isOrganizer ? 'Добавить' : 'Предложить'}
</NeonButton>
)} )}
</CardHeader> </div>
<CardContent>
{/* Add game form */} {/* Add game form */}
{showAddGame && ( {showAddGame && (
<div className="mb-6 p-4 bg-gray-900 rounded-lg space-y-3"> <div className="mb-6 p-4 glass rounded-xl space-y-3 border border-neon-500/20">
<Input <Input
placeholder="Название игры" placeholder="Название игры"
value={gameTitle} value={gameTitle}
@@ -910,16 +1013,20 @@ export function LobbyPage() {
onChange={(e) => setGameGenre(e.target.value)} onChange={(e) => setGameGenre(e.target.value)}
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={handleAddGame} isLoading={isAddingGame} disabled={!gameTitle || !gameUrl}> <NeonButton
onClick={handleAddGame}
isLoading={isAddingGame}
disabled={!gameTitle || !gameUrl}
>
{isOrganizer ? 'Добавить' : 'Предложить'} {isOrganizer ? 'Добавить' : 'Предложить'}
</Button> </NeonButton>
<Button variant="ghost" onClick={() => setShowAddGame(false)}> <NeonButton variant="outline" onClick={() => setShowAddGame(false)}>
Отмена Отмена
</Button> </NeonButton>
</div> </div>
{!isOrganizer && ( {!isOrganizer && (
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
Ваша игра будет отправлена на модерацию организаторам Игра будет отправлена на модерацию организаторам
</p> </p>
)} )}
</div> </div>
@@ -927,24 +1034,26 @@ export function LobbyPage() {
{/* Games */} {/* Games */}
{(() => { {(() => {
// Организаторы: показываем только одобренные (pending в секции модерации)
// Участники: показываем одобренные + свои pending
const visibleGames = isOrganizer const visibleGames = isOrganizer
? games.filter(g => g.status !== 'pending') ? games.filter(g => g.status !== 'pending')
: games.filter(g => g.status === 'approved' || (g.status === 'pending' && g.proposed_by?.id === user?.id)) : games.filter(g => g.status === 'approved' || (g.status === 'pending' && g.proposed_by?.id === user?.id))
return visibleGames.length === 0 ? ( return visibleGames.length === 0 ? (
<p className="text-center text-gray-400 py-8"> <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 ? 'Пока нет игр. Добавьте игры, чтобы начать!' : 'Пока нет одобренных игр. Предложите свою!'} {isOrganizer ? 'Пока нет игр. Добавьте игры, чтобы начать!' : 'Пока нет одобренных игр. Предложите свою!'}
</p> </p>
</div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{visibleGames.map((game) => renderGameCard(game, false))} {visibleGames.map((game) => renderGameCard(game, false))}
</div> </div>
) )
})()} })()}
</CardContent> </GlassCard>
</Card>
</div> </div>
) )
} }

View File

@@ -5,7 +5,8 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { marathonsApi } from '@/api' import { marathonsApi } from '@/api'
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui' import { NeonButton, Input, GlassCard } from '@/components/ui'
import { Gamepad2, LogIn, AlertCircle, Trophy, Users, Zap, Target } from 'lucide-react'
const loginSchema = z.object({ const loginSchema = z.object({
login: z.string().min(3, 'Логин должен быть не менее 3 символов'), login: z.string().min(3, 'Логин должен быть не менее 3 символов'),
@@ -51,17 +52,79 @@ export function LoginPage() {
} }
} }
const features = [
{ icon: <Trophy className="w-5 h-5" />, text: 'Соревнуйтесь с друзьями' },
{ icon: <Target className="w-5 h-5" />, text: 'Выполняйте челленджи' },
{ icon: <Zap className="w-5 h-5" />, text: 'Зарабатывайте очки' },
{ icon: <Users className="w-5 h-5" />, text: 'Создавайте марафоны' },
]
return ( return (
<div className="max-w-md mx-auto"> <div className="min-h-[80vh] flex items-center justify-center px-4 -mt-8">
<Card> {/* Background effects */}
<CardHeader> <div className="fixed inset-0 overflow-hidden pointer-events-none">
<CardTitle className="text-center">Вход</CardTitle> <div className="absolute top-1/4 -left-32 w-96 h-96 bg-neon-500/10 rounded-full blur-[100px]" />
</CardHeader> <div className="absolute bottom-1/4 -right-32 w-96 h-96 bg-accent-500/10 rounded-full blur-[100px]" />
<CardContent> </div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Bento Grid */}
<div className="relative w-full max-w-4xl">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-scale-in">
{/* Branding Block (left) */}
<GlassCard className="p-8 flex flex-col justify-center relative overflow-hidden" variant="neon">
{/* Background decoration */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-20 -left-20 w-48 h-48 bg-neon-500/20 rounded-full blur-[60px]" />
<div className="absolute -bottom-20 -right-20 w-48 h-48 bg-accent-500/20 rounded-full blur-[60px]" />
</div>
<div className="relative">
{/* Logo */}
<div className="flex justify-center md:justify-start mb-6">
<div className="w-20 h-20 rounded-2xl bg-neon-500/10 border border-neon-500/30 flex items-center justify-center shadow-[0_0_24px_rgba(34,211,238,0.25)]">
<Gamepad2 className="w-10 h-10 text-neon-500" />
</div>
</div>
{/* Title */}
<h1 className="text-3xl md:text-4xl font-bold text-white mb-2 text-center md:text-left">
Game Marathon
</h1>
<p className="text-gray-400 mb-8 text-center md:text-left">
Платформа для игровых соревнований
</p>
{/* Features */}
<div className="grid grid-cols-2 gap-3">
{features.map((feature, index) => (
<div
key={index}
className="flex items-center gap-2 p-3 rounded-xl bg-dark-700/50 border border-dark-600"
>
<div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center text-neon-400">
{feature.icon}
</div>
<span className="text-sm text-gray-300">{feature.text}</span>
</div>
))}
</div>
</div>
</GlassCard>
{/* Form Block (right) */}
<GlassCard className="p-8">
{/* Header */}
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-white mb-2">Добро пожаловать!</h2>
<p className="text-gray-400">Войдите, чтобы продолжить</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{(submitError || error) && ( {(submitError || error) && (
<div className="p-3 bg-red-500/20 border border-red-500 rounded-lg text-red-500 text-sm"> <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">
{submitError || error} <AlertCircle className="w-5 h-5 flex-shrink-0" />
<span>{submitError || error}</span>
</div> </div>
)} )}
@@ -69,6 +132,7 @@ export function LoginPage() {
label="Логин" label="Логин"
placeholder="Введите логин" placeholder="Введите логин"
error={errors.login?.message} error={errors.login?.message}
autoComplete="username"
{...register('login')} {...register('login')}
/> />
@@ -77,22 +141,40 @@ export function LoginPage() {
type="password" type="password"
placeholder="Введите пароль" placeholder="Введите пароль"
error={errors.password?.message} error={errors.password?.message}
autoComplete="current-password"
{...register('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> </Link>
</p> </p>
</form> </div>
</CardContent> </GlassCard>
</Card> </div>
{/* Decorative elements */}
<div className="absolute -top-4 -right-4 w-24 h-24 border border-neon-500/20 rounded-2xl -z-10 hidden md:block" />
<div className="absolute -bottom-4 -left-4 w-32 h-32 border border-accent-500/20 rounded-2xl -z-10 hidden md:block" />
</div>
</div> </div>
) )
} }

View File

@@ -2,15 +2,20 @@ import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, useNavigate, Link } from 'react-router-dom'
import { marathonsApi, eventsApi, challengesApi } from '@/api' import { marathonsApi, eventsApi, challengesApi } from '@/api'
import type { Marathon, ActiveEvent, Challenge } from '@/types' 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 { useAuthStore } from '@/store/auth'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm' import { useConfirm } from '@/store/confirm'
import { EventBanner } from '@/components/EventBanner' import { EventBanner } from '@/components/EventBanner'
import { EventControl } from '@/components/EventControl' import { EventControl } from '@/components/EventControl'
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed' 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, Sparkles
} from 'lucide-react'
import { format } from 'date-fns' import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
export function MarathonPage() { export function MarathonPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
@@ -27,6 +32,8 @@ export function MarathonPage() {
const [isJoining, setIsJoining] = useState(false) const [isJoining, setIsJoining] = useState(false)
const [isFinishing, setIsFinishing] = useState(false) const [isFinishing, setIsFinishing] = useState(false)
const [showEventControl, setShowEventControl] = useState(false) const [showEventControl, setShowEventControl] = useState(false)
const [showChallenges, setShowChallenges] = useState(false)
const [expandedGameId, setExpandedGameId] = useState<number | null>(null)
const activityFeedRef = useRef<ActivityFeedRef>(null) const activityFeedRef = useRef<ActivityFeedRef>(null)
useEffect(() => { useEffect(() => {
@@ -39,13 +46,11 @@ export function MarathonPage() {
const data = await marathonsApi.get(parseInt(id)) const data = await marathonsApi.get(parseInt(id))
setMarathon(data) setMarathon(data)
// Load event data if marathon is active
if (data.status === 'active' && data.my_participation) { if (data.status === 'active' && data.my_participation) {
const eventData = await eventsApi.getActive(parseInt(id)) const eventData = await eventsApi.getActive(parseInt(id))
setActiveEvent(eventData) setActiveEvent(eventData)
// Load challenges for event control if organizer // Load challenges for all participants
if (data.my_participation.role === 'organizer') {
try { try {
const challengesData = await challengesApi.list(parseInt(id)) const challengesData = await challengesApi.list(parseInt(id))
setChallenges(challengesData) setChallenges(challengesData)
@@ -53,7 +58,6 @@ export function MarathonPage() {
// Ignore if no challenges // Ignore if no challenges
} }
} }
}
} catch (error) { } catch (error) {
console.error('Failed to load marathon:', error) console.error('Failed to load marathon:', error)
navigate('/marathons') navigate('/marathons')
@@ -67,7 +71,6 @@ export function MarathonPage() {
try { try {
const eventData = await eventsApi.getActive(parseInt(id)) const eventData = await eventsApi.getActive(parseInt(id))
setActiveEvent(eventData) setActiveEvent(eventData)
// Refresh activity feed when event changes
activityFeedRef.current?.refresh() activityFeedRef.current?.refresh()
} catch (error) { } catch (error) {
console.error('Failed to refresh event:', error) console.error('Failed to refresh event:', error)
@@ -153,8 +156,9 @@ export function MarathonPage() {
if (isLoading || !marathon) { if (isLoading || !marathon) {
return ( return (
<div className="flex justify-center py-12"> <div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" /> <Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка марафона...</p>
</div> </div>
) )
} }
@@ -164,265 +168,358 @@ export function MarathonPage() {
const isCreator = marathon.creator.id === user?.id const isCreator = marathon.creator.id === user?.id
const canDelete = isCreator || user?.role === 'admin' 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 ( return (
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
{/* Back button */} {/* Back button */}
<Link to="/marathons" className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors"> <Link
<ArrowLeft className="w-4 h-4" /> 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> </Link>
<div className="flex flex-col lg:flex-row gap-6"> {/* Hero Banner */}
{/* Main content */} <div className="relative rounded-2xl overflow-hidden mb-8">
<div className="flex-1 min-w-0"> {/* Background */}
{/* Header */} <div className="absolute inset-0 bg-gradient-to-br from-neon-500/10 via-dark-800 to-accent-500/10" />
<div className="flex justify-between items-start mb-8"> <div className="absolute inset-0 bg-[linear-gradient(rgba(34,211,238,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(34,211,238,0.02)_1px,transparent_1px)] bg-[size:50px_50px]" />
<div>
<div className="flex items-center gap-3 mb-2"> <div className="relative p-8">
<h1 className="text-3xl font-bold text-white">{marathon.title}</h1> <div className="flex flex-col md:flex-row justify-between items-start gap-6">
<span className={`flex items-center gap-1 text-xs px-2 py-1 rounded ${ {/* 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 marathon.is_public
? 'bg-green-900/50 text-green-400' ? 'bg-green-500/20 text-green-400 border-green-500/30'
: 'bg-gray-700 text-gray-300' : 'bg-dark-700 text-gray-300 border-dark-600'
}`}> }`}>
{marathon.is_public ? ( {marathon.is_public ? <Globe className="w-3 h-3" /> : <Lock className="w-3 h-3" />}
<><Globe className="w-3 h-3" /> Открытый</> {marathon.is_public ? 'Открытый' : 'Закрытый'}
) : ( </span>
<><Lock className="w-3 h-3" /> Закрытый</> <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> </span>
</div> </div>
{marathon.description && ( {marathon.description && (
<p className="text-gray-400">{marathon.description}</p> <p className="text-gray-400 max-w-2xl">{marathon.description}</p>
)} )}
</div> </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' && ( {marathon.is_public && !isParticipant && marathon.status !== 'finished' && (
<Button onClick={handleJoinPublic} isLoading={isJoining}> <NeonButton onClick={handleJoinPublic} isLoading={isJoining} icon={<UserPlus className="w-4 h-4" />}>
<UserPlus className="w-4 h-4 mr-2" />
Присоединиться Присоединиться
</Button> </NeonButton>
)} )}
{/* Настройка для организаторов */}
{marathon.status === 'preparing' && isOrganizer && ( {marathon.status === 'preparing' && isOrganizer && (
<Link to={`/marathons/${id}/lobby`}> <Link to={`/marathons/${id}/lobby`}>
<Button variant="secondary"> <NeonButton variant="secondary" icon={<Settings className="w-4 h-4" />}>
<Settings className="w-4 h-4 mr-2" />
Настройка Настройка
</Button> </NeonButton>
</Link> </Link>
)} )}
{/* Предложить игру для участников (не организаторов) если разрешено */}
{marathon.status === 'preparing' && isParticipant && !isOrganizer && marathon.game_proposal_mode === 'all_participants' && ( {marathon.status === 'preparing' && isParticipant && !isOrganizer && marathon.game_proposal_mode === 'all_participants' && (
<Link to={`/marathons/${id}/lobby`}> <Link to={`/marathons/${id}/lobby`}>
<Button variant="secondary"> <NeonButton variant="secondary" icon={<Gamepad2 className="w-4 h-4" />}>
<Gamepad2 className="w-4 h-4 mr-2" />
Предложить игру Предложить игру
</Button> </NeonButton>
</Link> </Link>
)} )}
{marathon.status === 'active' && isParticipant && ( {marathon.status === 'active' && isParticipant && (
<Link to={`/marathons/${id}/play`}> <Link to={`/marathons/${id}/play`}>
<Button> <NeonButton icon={<Play className="w-4 h-4" />}>
<Play className="w-4 h-4 mr-2" />
Играть Играть
</Button> </NeonButton>
</Link> </Link>
)} )}
<Link to={`/marathons/${id}/leaderboard`}> <Link to={`/marathons/${id}/leaderboard`}>
<Button variant="secondary"> <NeonButton variant="outline" icon={<Trophy className="w-4 h-4" />}>
<Trophy className="w-4 h-4 mr-2" />
Рейтинг Рейтинг
</Button> </NeonButton>
</Link> </Link>
{marathon.status === 'active' && isOrganizer && ( {marathon.status === 'active' && isOrganizer && (
<Button <button
variant="secondary"
onClick={handleFinish} onClick={handleFinish}
isLoading={isFinishing} disabled={isFinishing}
className="text-yellow-400 hover:text-yellow-300 hover:bg-yellow-900/20" className="inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-semibold transition-all duration-200 border border-yellow-500/30 bg-dark-600 text-yellow-400 hover:bg-yellow-500/10 hover:border-yellow-500/50 disabled:opacity-50 disabled:cursor-not-allowed"
> >
<Flag className="w-4 h-4 mr-2" /> {isFinishing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Flag className="w-4 h-4" />}
Завершить Завершить
</Button> </button>
)} )}
{canDelete && ( {canDelete && (
<Button <NeonButton
variant="ghost" variant="ghost"
onClick={handleDelete} onClick={handleDelete}
isLoading={isDeleting} isLoading={isDeleting}
className="text-red-400 hover:text-red-300 hover:bg-red-900/20" className="!text-red-400 hover:!bg-red-500/10"
> icon={<Trash2 className="w-4 h-4" />}
<Trash2 className="w-4 h-4" /> />
</Button>
)} )}
</div> </div>
</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 */} {/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8"> <div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<Card> <StatsCard
<CardContent className="text-center py-4"> label="Участников"
<div className="text-2xl font-bold text-white">{marathon.participants_count}</div> value={marathon.participants_count}
<div className="text-sm text-gray-400 flex items-center justify-center gap-1"> icon={<Users className="w-5 h-5" />}
<Users className="w-4 h-4" /> color="neon"
Участников />
</div> <StatsCard
</CardContent> label="Игр"
</Card> value={marathon.games_count}
icon={<Gamepad2 className="w-5 h-5" />}
<Card> color="purple"
<CardContent className="text-center py-4"> />
<div className="text-2xl font-bold text-white">{marathon.games_count}</div> <StatsCard
<div className="text-sm text-gray-400">Игр</div> label="Начало"
</CardContent> value={marathon.start_date ? format(new Date(marathon.start_date), 'd MMM', { locale: ru }) : '-'}
</Card> icon={<Calendar className="w-5 h-5" />}
color="default"
<Card> />
<CardContent className="text-center py-4"> <StatsCard
<div className="text-2xl font-bold text-white"> label="Конец"
{marathon.start_date ? format(new Date(marathon.start_date), 'd MMM') : '-'} value={marathon.end_date ? format(new Date(marathon.end_date), 'd MMM', { locale: ru }) : '-'}
</div> icon={<CalendarCheck className="w-5 h-5" />}
<div className="text-sm text-gray-400 flex items-center justify-center gap-1"> color="default"
<Calendar className="w-4 h-4" /> />
Начало <StatsCard
</div> label="Статус"
</CardContent> value={status.label}
</Card> icon={<Target className="w-5 h-5" />}
color={marathon.status === 'active' ? 'neon' : marathon.status === 'preparing' ? 'default' : 'default'}
<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> </div>
{/* Active event banner */} {/* Active event banner */}
{marathon.status === 'active' && activeEvent?.event && ( {marathon.status === 'active' && activeEvent?.event && (
<div className="mb-8">
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} /> <EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
</div>
)} )}
{/* Event control for organizers */} {/* Event control for organizers */}
{marathon.status === 'active' && isOrganizer && ( {marathon.status === 'active' && isOrganizer && (
<Card className="mb-8"> <GlassCard>
<CardContent> <button
<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)} onClick={() => setShowEventControl(!showEventControl)}
className="w-full flex items-center justify-between"
> >
{showEventControl ? 'Скрыть' : 'Показать'} <div className="flex items-center gap-3">
</Button> <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>
<div className="text-left">
<h3 className="font-semibold text-white">Управление событиями</h3>
<p className="text-sm text-gray-400">Активируйте бонусы для участников</p>
</div>
</div>
{showEventControl ? (
<ChevronUp className="w-5 h-5 text-gray-400" />
) : (
<ChevronDown className="w-5 h-5 text-gray-400" />
)}
</button>
{showEventControl && activeEvent && ( {showEventControl && activeEvent && (
<div className="mt-6 pt-6 border-t border-dark-600">
<EventControl <EventControl
marathonId={marathon.id} marathonId={marathon.id}
activeEvent={activeEvent} activeEvent={activeEvent}
challenges={challenges} challenges={challenges}
onEventChange={refreshEvent} onEventChange={refreshEvent}
/> />
</div>
)} )}
</CardContent> </GlassCard>
</Card>
)} )}
{/* Invite link */} {/* Invite link */}
{marathon.status !== 'finished' && ( {marathon.status !== 'finished' && (
<Card className="mb-8"> <GlassCard>
<CardContent> <div className="flex items-center gap-3 mb-4">
<h3 className="font-medium text-white mb-3">Ссылка для приглашения</h3> <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>
<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"> <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"> <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()} {getInviteLink()}
</code> </code>
<Button variant="secondary" onClick={copyInviteLink}> <NeonButton variant="secondary" onClick={copyInviteLink} icon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}>
{copied ? ( {copied ? 'Скопировано!' : 'Копировать'}
<> </NeonButton>
<Check className="w-4 h-4 mr-2" />
Скопировано!
</>
) : (
<>
<Copy className="w-4 h-4 mr-2" />
Копировать
</>
)}
</Button>
</div> </div>
<p className="text-sm text-gray-500 mt-2"> </GlassCard>
Поделитесь этой ссылкой с друзьями, чтобы они могли присоединиться к марафону
</p>
</CardContent>
</Card>
)} )}
{/* My stats */} {/* My stats */}
{marathon.my_participation && ( {marathon.my_participation && (
<Card> <GlassCard variant="neon">
<CardContent> <h3 className="font-semibold text-white mb-4 flex items-center gap-2">
<h3 className="font-medium text-white mb-4">Ваша статистика</h3> <Star className="w-5 h-5 text-yellow-500" />
<div className="grid grid-cols-3 gap-4 text-center"> Ваша статистика
<div> </h3>
<div className="text-2xl font-bold text-primary-500"> <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} {marathon.my_participation.total_points}
</div> </div>
<div className="text-sm text-gray-400">Очков</div> <div className="text-sm text-gray-400 mt-1">Очков</div>
</div> </div>
<div> <div className="text-center p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="text-2xl font-bold text-yellow-500"> <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}
{marathon.my_participation.current_streak > 0 && (
<span className="text-lg">🔥</span>
)}
</div> </div>
<div className="text-sm text-gray-400">Серия</div> <div className="text-sm text-gray-400 mt-1">Серия</div>
</div> </div>
<div> <div className="text-center p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="text-2xl font-bold text-gray-400"> <div className="text-3xl font-bold text-gray-400 flex items-center justify-center gap-1">
{marathon.my_participation.drop_count} {marathon.my_participation.drop_count}
<TrendingDown className="w-5 h-5" />
</div> </div>
<div className="text-sm text-gray-400">Пропусков</div> <div className="text-sm text-gray-400 mt-1">Пропусков</div>
</div> </div>
</div> </div>
</CardContent> </GlassCard>
</Card> )}
{/* All challenges viewer */}
{marathon.status === 'active' && isParticipant && challenges.length > 0 && (
<GlassCard>
<button
onClick={() => setShowChallenges(!showChallenges)}
className="w-full flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
<Sparkles className="w-5 h-5 text-accent-400" />
</div>
<div className="text-left">
<h3 className="font-semibold text-white">Все задания</h3>
<p className="text-sm text-gray-400">{challenges.length} заданий для {new Set(challenges.map(c => c.game.id)).size} игр</p>
</div>
</div>
{showChallenges ? (
<ChevronUp className="w-5 h-5 text-gray-400" />
) : (
<ChevronDown className="w-5 h-5 text-gray-400" />
)}
</button>
{showChallenges && (
<div className="mt-6 pt-6 border-t border-dark-600 space-y-4">
{/* Group challenges by game */}
{Array.from(new Set(challenges.map(c => c.game.id))).map(gameId => {
const gameChallenges = challenges.filter(c => c.game.id === gameId)
const game = gameChallenges[0]?.game
if (!game) return null
const isExpanded = expandedGameId === gameId
return (
<div key={gameId} className="glass rounded-xl overflow-hidden border border-dark-600">
<button
onClick={() => setExpandedGameId(isExpanded ? null : gameId)}
className="w-full flex items-center justify-between p-4 hover:bg-dark-700/50 transition-colors"
>
<div className="flex items-center gap-3">
<span className="text-gray-400">
{isExpanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</span>
<div className="w-8 h-8 rounded-lg 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-4 h-4 text-neon-400" />
</div>
<div className="text-left">
<h4 className="font-semibold text-white">{game.title}</h4>
<span className="text-xs text-gray-400">{gameChallenges.length} заданий</span>
</div>
</div>
</button>
{isExpanded && (
<div className="border-t border-dark-600 p-4 space-y-2 bg-dark-800/30">
{gameChallenges.map(challenge => (
<div
key={challenge.id}
className="p-3 bg-dark-700/50 rounded-lg border border-dark-600"
>
<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-neon-400 font-semibold">
+{challenge.points}
</span>
<span className="text-xs text-gray-500">
{challenge.type === 'completion' ? 'Прохождение' :
challenge.type === 'no_death' ? 'Без смертей' :
challenge.type === 'speedrun' ? 'Спидран' :
challenge.type === 'collection' ? 'Коллекция' :
challenge.type === 'achievement' ? 'Достижение' : 'Челлендж-ран'}
</span>
</div>
<h5 className="text-sm font-medium text-white">{challenge.title}</h5>
<p className="text-xs text-gray-400 mt-1">{challenge.description}</p>
{challenge.proof_hint && (
<p className="text-xs text-gray-500 mt-2 flex items-center gap-1">
<Target className="w-3 h-3" />
Пруф: {challenge.proof_hint}
</p>
)}
</div>
))}
</div>
)}
</div>
)
})}
</div>
)}
</GlassCard>
)} )}
</div> </div>
{/* Activity Feed - right sidebar */} {/* Activity Feed - right sidebar */}
{isParticipant && ( {isParticipant && (
<div className="lg:w-96 flex-shrink-0"> <div className="lg:w-96 flex-shrink-0">
<div className="lg:sticky lg:top-4"> <div className="lg:sticky lg:top-24">
<ActivityFeed <ActivityFeed
ref={activityFeedRef} ref={activityFeedRef}
marathonId={marathon.id} marathonId={marathon.id}

View File

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

View File

@@ -1,33 +1,62 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Button } from '@/components/ui' import { NeonButton } from '@/components/ui'
import { Gamepad2, Home, Ghost } from 'lucide-react' import { Gamepad2, Home, Ghost, Sparkles } from 'lucide-react'
export function NotFoundPage() { export function NotFoundPage() {
return ( return (
<div className="min-h-[60vh] flex flex-col items-center justify-center text-center px-4"> <div className="min-h-[70vh] flex flex-col items-center justify-center text-center px-4">
{/* Иконка с анимацией */} {/* Background effects */}
<div className="relative mb-8"> <div className="fixed inset-0 overflow-hidden pointer-events-none">
<Ghost className="w-32 h-32 text-gray-700 animate-bounce" /> <div className="absolute top-1/3 -left-32 w-96 h-96 bg-accent-500/5 rounded-full blur-[100px]" />
<Gamepad2 className="w-12 h-12 text-primary-500 absolute -bottom-2 -right-2" /> <div className="absolute bottom-1/3 -right-32 w-96 h-96 bg-neon-500/5 rounded-full blur-[100px]" />
</div> </div>
{/* Заголовок */} {/* Icon */}
<h1 className="text-7xl font-bold text-white mb-4">404</h1> <div className="relative mb-8 animate-float">
<h2 className="text-2xl font-semibold text-gray-400 mb-2"> <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> </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> </p>
{/* Кнопка */} {/* Button */}
<Link to="/"> <Link to="/">
<Button size="lg" className="flex items-center gap-2"> <NeonButton size="lg" icon={<Home className="w-5 h-5" />}>
<Home className="w-5 h-5" />
На главную На главную
</Button> </NeonButton>
</Link> </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> </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 type { UserStats } from '@/types'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { import {
Button, Input, Card, CardHeader, CardTitle, CardContent, clearAvatarCache NeonButton, Input, GlassCard, StatsCard, clearAvatarCache
} from '@/components/ui' } from '@/components/ui'
import { import {
User, Camera, Trophy, Target, CheckCircle, Flame, User, Camera, Trophy, Target, CheckCircle, Flame,
Loader2, MessageCircle, Link2, Link2Off, ExternalLink, Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
Eye, EyeOff, Save, KeyRound Eye, EyeOff, Save, KeyRound, Shield
} from 'lucide-react' } from 'lucide-react'
// Схемы валидации // Schemas
const nicknameSchema = z.object({ const nicknameSchema = z.object({
nickname: z.string().min(2, 'Минимум 2 символа').max(50, 'Максимум 50 символов'), nickname: z.string().min(2, 'Минимум 2 символа').max(50, 'Максимум 50 символов'),
}) })
@@ -33,10 +33,10 @@ type NicknameForm = z.infer<typeof nicknameSchema>
type PasswordForm = z.infer<typeof passwordSchema> type PasswordForm = z.infer<typeof passwordSchema>
export function ProfilePage() { export function ProfilePage() {
const { user, updateUser } = useAuthStore() const { user, updateUser, avatarVersion, bumpAvatarVersion } = useAuthStore()
const toast = useToast() const toast = useToast()
// Состояние // State
const [stats, setStats] = useState<UserStats | null>(null) const [stats, setStats] = useState<UserStats | null>(null)
const [isLoadingStats, setIsLoadingStats] = useState(true) const [isLoadingStats, setIsLoadingStats] = useState(true)
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false) const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
@@ -53,7 +53,7 @@ export function ProfilePage() {
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
// Формы // Forms
const nicknameForm = useForm<NicknameForm>({ const nicknameForm = useForm<NicknameForm>({
resolver: zodResolver(nicknameSchema), resolver: zodResolver(nicknameSchema),
defaultValues: { nickname: user?.nickname || '' }, defaultValues: { nickname: user?.nickname || '' },
@@ -64,7 +64,7 @@ export function ProfilePage() {
defaultValues: { current_password: '', new_password: '', confirm_password: '' }, defaultValues: { current_password: '', new_password: '', confirm_password: '' },
}) })
// Загрузка статистики // Load stats
useEffect(() => { useEffect(() => {
loadStats() loadStats()
return () => { return () => {
@@ -72,33 +72,59 @@ export function ProfilePage() {
} }
}, []) }, [])
// Загрузка аватарки через API // Ref для отслеживания текущего blob URL
const avatarBlobRef = useRef<string | null>(null)
// Load avatar via API
useEffect(() => { useEffect(() => {
if (user?.id && user?.avatar_url) { if (!user?.id || !user?.avatar_url) {
loadAvatar(user.id)
} else {
setIsLoadingAvatar(false) setIsLoadingAvatar(false)
return
} }
return () => {
if (avatarBlobUrl) {
URL.revokeObjectURL(avatarBlobUrl)
}
}
}, [user?.id, user?.avatar_url])
const loadAvatar = async (userId: number) => { let cancelled = false
const bustCache = avatarVersion > 0
setIsLoadingAvatar(true) setIsLoadingAvatar(true)
try { usersApi.getAvatarUrl(user.id, bustCache)
const url = await usersApi.getAvatarUrl(userId) .then(url => {
if (cancelled) {
URL.revokeObjectURL(url)
return
}
// Очищаем старый blob URL
if (avatarBlobRef.current) {
URL.revokeObjectURL(avatarBlobRef.current)
}
avatarBlobRef.current = url
setAvatarBlobUrl(url) setAvatarBlobUrl(url)
} catch { })
.catch(() => {
if (!cancelled) {
setAvatarBlobUrl(null) setAvatarBlobUrl(null)
} finally { }
})
.finally(() => {
if (!cancelled) {
setIsLoadingAvatar(false) setIsLoadingAvatar(false)
} }
} })
// Обновляем форму никнейма при изменении user return () => {
cancelled = true
}
}, [user?.id, user?.avatar_url, avatarVersion])
// Cleanup blob URL on unmount
useEffect(() => {
return () => {
if (avatarBlobRef.current) {
URL.revokeObjectURL(avatarBlobRef.current)
}
}
}, [])
// Update nickname form when user changes
useEffect(() => { useEffect(() => {
if (user?.nickname) { if (user?.nickname) {
nicknameForm.reset({ nickname: user.nickname }) nicknameForm.reset({ nickname: user.nickname })
@@ -116,7 +142,7 @@ export function ProfilePage() {
} }
} }
// Обновление никнейма // Update nickname
const onNicknameSubmit = async (data: NicknameForm) => { const onNicknameSubmit = async (data: NicknameForm) => {
try { try {
const updatedUser = await usersApi.updateNickname(data) const updatedUser = await usersApi.updateNickname(data)
@@ -127,7 +153,7 @@ export function ProfilePage() {
} }
} }
// Загрузка аватара // Upload avatar
const handleAvatarClick = () => { const handleAvatarClick = () => {
fileInputRef.current?.click() fileInputRef.current?.click()
} }
@@ -136,7 +162,6 @@ export function ProfilePage() {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
// Валидация
if (!file.type.startsWith('image/')) { if (!file.type.startsWith('image/')) {
toast.error('Файл должен быть изображением') toast.error('Файл должен быть изображением')
return return
@@ -150,15 +175,11 @@ export function ProfilePage() {
try { try {
const updatedUser = await usersApi.uploadAvatar(file) const updatedUser = await usersApi.uploadAvatar(file)
updateUser({ avatar_url: updatedUser.avatar_url }) updateUser({ avatar_url: updatedUser.avatar_url })
// Перезагружаем аватарку через API
if (user?.id) { if (user?.id) {
// Очищаем старый blob URL и глобальный кэш
if (avatarBlobUrl) {
URL.revokeObjectURL(avatarBlobUrl)
}
clearAvatarCache(user.id) clearAvatarCache(user.id)
await loadAvatar(user.id)
} }
// Bump version - это вызовет перезагрузку через useEffect
bumpAvatarVersion()
toast.success('Аватар обновлен') toast.success('Аватар обновлен')
} catch { } catch {
toast.error('Не удалось загрузить аватар') toast.error('Не удалось загрузить аватар')
@@ -167,7 +188,7 @@ export function ProfilePage() {
} }
} }
// Смена пароля // Change password
const onPasswordSubmit = async (data: PasswordForm) => { const onPasswordSubmit = async (data: PasswordForm) => {
try { try {
await usersApi.changePassword({ await usersApi.changePassword({
@@ -184,7 +205,7 @@ export function ProfilePage() {
} }
} }
// Telegram функции // Telegram functions
const startPolling = () => { const startPolling = () => {
setIsPolling(true) setIsPolling(true)
let attempts = 0 let attempts = 0
@@ -245,26 +266,28 @@ export function ProfilePage() {
} }
const isLinked = !!user?.telegram_id const isLinked = !!user?.telegram_id
// Приоритет: загруженная аватарка (blob) > телеграм аватарка
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
return ( return (
<div className="max-w-2xl mx-auto space-y-6"> <div className="max-w-3xl mx-auto space-y-6">
<h1 className="text-2xl font-bold text-white">Мой профиль</h1> {/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Мой профиль</h1>
<p className="text-gray-400">Настройки вашего аккаунта</p>
</div>
{/* Карточка профиля */} {/* Profile Card */}
<Card> <GlassCard variant="neon">
<CardContent className="pt-6"> <div className="flex flex-col sm:flex-row items-center sm:items-start gap-6">
<div className="flex items-start gap-6"> {/* Avatar */}
{/* Аватар */}
<div className="relative group flex-shrink-0"> <div className="relative group flex-shrink-0">
{isLoadingAvatar ? ( {isLoadingAvatar ? (
<div className="w-24 h-24 rounded-full bg-gray-700 animate-pulse" /> <div className="w-28 h-28 rounded-2xl bg-dark-700 skeleton" />
) : ( ) : (
<button <button
onClick={handleAvatarClick} onClick={handleAvatarClick}
disabled={isUploadingAvatar} disabled={isUploadingAvatar}
className="relative w-24 h-24 rounded-full overflow-hidden bg-gray-700 hover:opacity-80 transition-opacity" 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_20px_rgba(34,211,238,0.25)]"
> >
{displayAvatar ? ( {displayAvatar ? (
<img <img
@@ -273,15 +296,15 @@ export function ProfilePage() {
className="w-full h-full object-cover" 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-neon-500/20 to-accent-500/20">
<User className="w-12 h-12 text-gray-500" /> <User className="w-12 h-12 text-gray-500" />
</div> </div>
)} )}
<div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"> <div className="absolute inset-0 bg-dark-900/70 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
{isUploadingAvatar ? ( {isUploadingAvatar ? (
<Loader2 className="w-6 h-6 text-white animate-spin" /> <Loader2 className="w-8 h-8 text-neon-500 animate-spin" />
) : ( ) : (
<Camera className="w-6 h-6 text-white" /> <Camera className="w-8 h-8 text-neon-500" />
)} )}
</div> </div>
</button> </button>
@@ -295,90 +318,96 @@ export function ProfilePage() {
/> />
</div> </div>
{/* Форма никнейма */} {/* Nickname Form */}
<div className="flex-1"> <div className="flex-1 w-full sm:w-auto">
<form onSubmit={nicknameForm.handleSubmit(onNicknameSubmit)} className="space-y-4"> <form onSubmit={nicknameForm.handleSubmit(onNicknameSubmit)} className="space-y-4">
<Input <Input
label="Никнейм" label="Никнейм"
{...nicknameForm.register('nickname')} {...nicknameForm.register('nickname')}
error={nicknameForm.formState.errors.nickname?.message} error={nicknameForm.formState.errors.nickname?.message}
/> />
<Button <NeonButton
type="submit" type="submit"
size="sm" size="sm"
isLoading={nicknameForm.formState.isSubmitting} isLoading={nicknameForm.formState.isSubmitting}
disabled={!nicknameForm.formState.isDirty} disabled={!nicknameForm.formState.isDirty}
icon={<Save className="w-4 h-4" />}
> >
<Save className="w-4 h-4 mr-2" />
Сохранить Сохранить
</Button> </NeonButton>
</form> </form>
</div> </div>
</div> </div>
</CardContent> </GlassCard>
</Card>
{/* Статистика */} {/* Stats */}
<Card> <div>
<CardHeader> <h2 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
<CardTitle className="flex items-center gap-2">
<Trophy className="w-5 h-5 text-yellow-500" /> <Trophy className="w-5 h-5 text-yellow-500" />
Статистика Статистика
</CardTitle> </h2>
</CardHeader>
<CardContent>
{isLoadingStats ? ( {isLoadingStats ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => ( {[...Array(4)].map((_, i) => (
<div key={i} className="bg-gray-900 rounded-lg p-4 text-center"> <div key={i} className="glass rounded-xl p-4">
<div className="w-6 h-6 bg-gray-700 rounded mx-auto mb-2 animate-pulse" /> <div className="w-12 h-12 bg-dark-700 rounded-lg mb-3 skeleton" />
<div className="h-8 w-12 bg-gray-700 rounded mx-auto mb-2 animate-pulse" /> <div className="h-8 w-16 bg-dark-700 rounded mb-2 skeleton" />
<div className="h-4 w-16 bg-gray-700 rounded mx-auto animate-pulse" /> <div className="h-4 w-20 bg-dark-700 rounded skeleton" />
</div> </div>
))} ))}
</div> </div>
) : stats ? ( ) : stats ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-900 rounded-lg p-4 text-center"> <StatsCard
<Target className="w-6 h-6 text-blue-400 mx-auto mb-2" /> label="Марафонов"
<div className="text-2xl font-bold text-white">{stats.marathons_count}</div> value={stats.marathons_count}
<div className="text-sm text-gray-400">Марафонов</div> icon={<Target className="w-6 h-6" />}
</div> color="neon"
<div className="bg-gray-900 rounded-lg p-4 text-center"> />
<Trophy className="w-6 h-6 text-yellow-500 mx-auto mb-2" /> <StatsCard
<div className="text-2xl font-bold text-white">{stats.wins_count}</div> label="Побед"
<div className="text-sm text-gray-400">Побед</div> value={stats.wins_count}
</div> icon={<Trophy className="w-6 h-6" />}
<div className="bg-gray-900 rounded-lg p-4 text-center"> color="purple"
<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> <StatsCard
<div className="text-sm text-gray-400">Заданий</div> label="Заданий"
</div> value={stats.completed_assignments}
<div className="bg-gray-900 rounded-lg p-4 text-center"> icon={<CheckCircle className="w-6 h-6" />}
<Flame className="w-6 h-6 text-orange-500 mx-auto mb-2" /> color="neon"
<div className="text-2xl font-bold text-white">{stats.total_points_earned}</div> />
<div className="text-sm text-gray-400">Очков</div> <StatsCard
</div> label="Очков"
value={stats.total_points_earned}
icon={<Flame className="w-6 h-6" />}
color="pink"
/>
</div> </div>
) : ( ) : (
<p className="text-gray-400 text-center">Не удалось загрузить статистику</p> <GlassCard className="text-center py-8">
<p className="text-gray-400">Не удалось загрузить статистику</p>
</GlassCard>
)} )}
</CardContent> </div>
</Card>
{/* Telegram */} {/* Telegram */}
<Card> <GlassCard>
<CardHeader> <div className="flex items-center gap-3 mb-6">
<CardTitle className="flex items-center gap-2"> <div className="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center">
<MessageCircle className="w-5 h-5 text-blue-400" /> <MessageCircle className="w-6 h-6 text-blue-400" />
Telegram </div>
</CardTitle> <div>
</CardHeader> <h2 className="text-lg font-semibold text-white">Telegram</h2>
<CardContent> <p className="text-sm text-gray-400">
{isLinked ? 'Аккаунт привязан' : 'Привяжите для уведомлений'}
</p>
</div>
</div>
{isLinked ? ( {isLinked ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center gap-4 p-4 bg-gray-900 rounded-lg"> <div className="flex items-center gap-4 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="w-12 h-12 rounded-full bg-blue-500/20 flex items-center justify-center overflow-hidden"> <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 ? ( {user?.telegram_avatar_url ? (
<img <img
src={user.telegram_avatar_url} src={user.telegram_avatar_url}
@@ -386,7 +415,7 @@ export function ProfilePage() {
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : ( ) : (
<Link2 className="w-6 h-6 text-blue-400" /> <Link2 className="w-7 h-7 text-blue-400" />
)} )}
</div> </div>
<div className="flex-1"> <div className="flex-1">
@@ -397,53 +426,61 @@ export function ProfilePage() {
<p className="text-blue-400 text-sm">@{user.telegram_username}</p> <p className="text-blue-400 text-sm">@{user.telegram_username}</p>
)} )}
</div> </div>
<Button <NeonButton
variant="danger" variant="danger"
size="sm" size="sm"
onClick={handleUnlinkTelegram} onClick={handleUnlinkTelegram}
isLoading={telegramLoading} isLoading={telegramLoading}
icon={<Link2Off className="w-4 h-4" />}
> >
<Link2Off className="w-4 h-4 mr-2" />
Отвязать Отвязать
</Button> </NeonButton>
</div> </div>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-gray-400"> <p className="text-gray-400">
Привяжи Telegram для получения уведомлений о событиях и марафонах. Привяжите Telegram для получения уведомлений о событиях и марафонах.
</p> </p>
{isPolling ? ( {isPolling ? (
<div className="p-4 bg-blue-500/20 border border-blue-500/50 rounded-lg"> <div className="p-4 bg-blue-500/10 border border-blue-500/30 rounded-xl">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Loader2 className="w-5 h-5 text-blue-400 animate-spin" /> <Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
<p className="text-blue-400">Ожидание привязки...</p> <p className="text-blue-400">Ожидание привязки...</p>
</div> </div>
</div> </div>
) : ( ) : (
<Button onClick={handleLinkTelegram} isLoading={telegramLoading}> <NeonButton
<ExternalLink className="w-4 h-4 mr-2" /> onClick={handleLinkTelegram}
isLoading={telegramLoading}
icon={<ExternalLink className="w-4 h-4" />}
>
Привязать Telegram Привязать Telegram
</Button> </NeonButton>
)} )}
</div> </div>
)} )}
</CardContent> </GlassCard>
</Card>
{/* 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>
{/* Смена пароля */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="w-5 h-5 text-gray-400" />
Безопасность
</CardTitle>
</CardHeader>
<CardContent>
{!showPasswordForm ? ( {!showPasswordForm ? (
<Button variant="secondary" onClick={() => setShowPasswordForm(true)}> <NeonButton
onClick={() => setShowPasswordForm(true)}
icon={<KeyRound className="w-4 h-4" />}
>
Сменить пароль Сменить пароль
</Button> </NeonButton>
) : ( ) : (
<form onSubmit={passwordForm.handleSubmit(onPasswordSubmit)} className="space-y-4"> <form onSubmit={passwordForm.handleSubmit(onPasswordSubmit)} className="space-y-4">
<div className="relative"> <div className="relative">
@@ -456,7 +493,7 @@ export function ProfilePage() {
<button <button
type="button" type="button"
onClick={() => setShowCurrentPassword(!showCurrentPassword)} onClick={() => setShowCurrentPassword(!showCurrentPassword)}
className="absolute right-3 top-8 text-gray-400 hover:text-white" 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" />} {showCurrentPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button> </button>
@@ -472,7 +509,7 @@ export function ProfilePage() {
<button <button
type="button" type="button"
onClick={() => setShowNewPassword(!showNewPassword)} onClick={() => setShowNewPassword(!showNewPassword)}
className="absolute right-3 top-8 text-gray-400 hover:text-white" 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" />} {showNewPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button> </button>
@@ -485,11 +522,15 @@ export function ProfilePage() {
error={passwordForm.formState.errors.confirm_password?.message} error={passwordForm.formState.errors.confirm_password?.message}
/> />
<div className="flex gap-2"> <div className="flex gap-3">
<Button type="submit" isLoading={passwordForm.formState.isSubmitting}> <NeonButton
type="submit"
isLoading={passwordForm.formState.isSubmitting}
icon={<Save className="w-4 h-4" />}
>
Сменить пароль Сменить пароль
</Button> </NeonButton>
<Button <NeonButton
type="button" type="button"
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
@@ -498,12 +539,11 @@ export function ProfilePage() {
}} }}
> >
Отмена Отмена
</Button> </NeonButton>
</div> </div>
</form> </form>
)} )}
</CardContent> </GlassCard>
</Card>
</div> </div>
) )
} }

View File

@@ -5,7 +5,8 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { marathonsApi } from '@/api' import { marathonsApi } from '@/api'
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui' import { NeonButton, Input, GlassCard } from '@/components/ui'
import { Gamepad2, UserPlus, AlertCircle, Trophy, Users, Zap, Target, Sparkles } from 'lucide-react'
const registerSchema = z.object({ const registerSchema = z.object({
login: z login: z
@@ -67,17 +68,106 @@ export function RegisterPage() {
} }
} }
const features = [
{ icon: <Trophy className="w-5 h-5" />, text: 'Соревнуйтесь с друзьями' },
{ icon: <Target className="w-5 h-5" />, text: 'Выполняйте челленджи' },
{ icon: <Zap className="w-5 h-5" />, text: 'Зарабатывайте очки' },
{ icon: <Users className="w-5 h-5" />, text: 'Создавайте марафоны' },
]
return ( return (
<div className="max-w-md mx-auto"> <div className="min-h-[80vh] flex items-center justify-center px-4 py-8">
<Card> {/* Background effects */}
<CardHeader> <div className="fixed inset-0 overflow-hidden pointer-events-none">
<CardTitle className="text-center">Регистрация</CardTitle> <div className="absolute top-1/3 -right-32 w-96 h-96 bg-accent-500/10 rounded-full blur-[100px]" />
</CardHeader> <div className="absolute bottom-1/3 -left-32 w-96 h-96 bg-neon-500/10 rounded-full blur-[100px]" />
<CardContent> </div>
{/* Bento Grid */}
<div className="relative w-full max-w-4xl">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-scale-in">
{/* Branding Block (left) */}
<GlassCard className="p-8 flex flex-col justify-center relative overflow-hidden order-2 md:order-1" variant="neon">
{/* Background decoration */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-20 -left-20 w-48 h-48 bg-accent-500/20 rounded-full blur-[60px]" />
<div className="absolute -bottom-20 -right-20 w-48 h-48 bg-neon-500/20 rounded-full blur-[60px]" />
</div>
<div className="relative">
{/* Logo */}
<div className="flex justify-center md:justify-start mb-6">
<div className="w-20 h-20 rounded-2xl bg-accent-500/10 border border-accent-500/30 flex items-center justify-center shadow-[0_0_40px_rgba(147,51,234,0.3)]">
<Gamepad2 className="w-10 h-10 text-accent-500" />
</div>
</div>
{/* Title */}
<h1 className="text-3xl md:text-4xl font-bold text-white mb-2 text-center md:text-left">
Game Marathon
</h1>
<p className="text-gray-400 mb-6 text-center md:text-left">
Присоединяйтесь к игровому сообществу
</p>
{/* Benefits */}
<div className="p-4 rounded-xl bg-dark-700/50 border border-dark-600 mb-6">
<div className="flex items-center gap-2 mb-3">
<Sparkles className="w-5 h-5 text-accent-400" />
<span className="text-white font-semibold">Что вас ждет:</span>
</div>
<ul className="space-y-2 text-sm text-gray-400">
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-neon-500" />
Создавайте игровые марафоны
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-accent-500" />
Выполняйте уникальные челленджи
</li>
<li className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full bg-pink-500" />
Соревнуйтесь за первое место
</li>
</ul>
</div>
{/* Features */}
<div className="grid grid-cols-2 gap-3">
{features.map((feature, index) => (
<div
key={index}
className="flex items-center gap-2 p-3 rounded-xl bg-dark-700/50 border border-dark-600"
>
<div className="w-8 h-8 rounded-lg bg-accent-500/20 flex items-center justify-center text-accent-400">
{feature.icon}
</div>
<span className="text-sm text-gray-300">{feature.text}</span>
</div>
))}
</div>
</div>
</GlassCard>
{/* Form Block (right) */}
<GlassCard className="p-8 order-1 md:order-2">
{/* Header */}
<div className="text-center mb-6">
<div className="flex justify-center mb-4 md:hidden">
<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>
<h2 className="text-2xl font-bold text-white mb-2">Создать аккаунт</h2>
<p className="text-gray-400">Начните играть уже сегодня</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{(submitError || error) && ( {(submitError || error) && (
<div className="p-3 bg-red-500/20 border border-red-500 rounded-lg text-red-500 text-sm"> <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">
{submitError || error} <AlertCircle className="w-5 h-5 flex-shrink-0" />
<span>{submitError || error}</span>
</div> </div>
)} )}
@@ -85,12 +175,13 @@ export function RegisterPage() {
label="Логин" label="Логин"
placeholder="Придумайте логин" placeholder="Придумайте логин"
error={errors.login?.message} error={errors.login?.message}
autoComplete="username"
{...register('login')} {...register('login')}
/> />
<Input <Input
label="Никнейм" label="Никнейм"
placeholder="Придумайте никнейм" placeholder="Как вас называть?"
error={errors.nickname?.message} error={errors.nickname?.message}
{...register('nickname')} {...register('nickname')}
/> />
@@ -100,6 +191,7 @@ export function RegisterPage() {
type="password" type="password"
placeholder="Придумайте пароль" placeholder="Придумайте пароль"
error={errors.password?.message} error={errors.password?.message}
autoComplete="new-password"
{...register('password')} {...register('password')}
/> />
@@ -108,22 +200,41 @@ export function RegisterPage() {
type="password" type="password"
placeholder="Повторите пароль" placeholder="Повторите пароль"
error={errors.confirmPassword?.message} error={errors.confirmPassword?.message}
autoComplete="new-password"
{...register('confirmPassword')} {...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> </Link>
</p> </p>
</form> </div>
</CardContent> </GlassCard>
</Card> </div>
{/* Decorative elements */}
<div className="absolute -top-4 -left-4 w-24 h-24 border border-accent-500/20 rounded-2xl -z-10 hidden md:block" />
<div className="absolute -bottom-4 -right-4 w-32 h-32 border border-neon-500/20 rounded-2xl -z-10 hidden md:block" />
</div>
</div> </div>
) )
} }

View File

@@ -0,0 +1,143 @@
import { Link } from 'react-router-dom'
import { NeonButton } from '@/components/ui'
import { Home, Sparkles, RefreshCw, ServerCrash, Flame, Zap } from 'lucide-react'
export function ServerErrorPage() {
const handleRefresh = () => {
window.location.href = '/'
}
return (
<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/4 -left-32 w-96 h-96 bg-red-500/5 rounded-full blur-[100px]" />
<div className="absolute bottom-1/4 -right-32 w-96 h-96 bg-orange-500/5 rounded-full blur-[100px]" />
</div>
{/* Server icon */}
<div className="relative mb-8">
{/* Smoke/fire effect */}
<div className="absolute -top-6 left-1/2 -translate-x-1/2 flex gap-3">
<Flame className="w-6 h-6 text-orange-500/60 animate-flicker" style={{ animationDelay: '0s' }} />
<Flame className="w-5 h-5 text-red-500/50 animate-flicker" style={{ animationDelay: '0.2s' }} />
<Flame className="w-6 h-6 text-orange-500/60 animate-flicker" style={{ animationDelay: '0.4s' }} />
</div>
{/* Server with error */}
<div className="relative">
<div className="w-32 h-32 rounded-2xl bg-dark-700/80 border-2 border-red-500/30 flex items-center justify-center shadow-[0_0_30px_rgba(239,68,68,0.2)]">
<ServerCrash className="w-16 h-16 text-red-400" />
</div>
{/* Error indicator */}
<div className="absolute -bottom-2 -right-2 w-10 h-10 rounded-xl bg-red-500/20 border border-red-500/40 flex items-center justify-center animate-pulse">
<Zap className="w-5 h-5 text-red-400" />
</div>
{/* Sparks */}
<div className="absolute top-2 -left-3 w-2 h-2 rounded-full bg-yellow-400 animate-spark" style={{ animationDelay: '0s' }} />
<div className="absolute top-6 -right-2 w-1.5 h-1.5 rounded-full bg-orange-400 animate-spark" style={{ animationDelay: '0.3s' }} />
<div className="absolute bottom-4 -left-2 w-1.5 h-1.5 rounded-full bg-red-400 animate-spark" style={{ animationDelay: '0.6s' }} />
</div>
{/* Glow effect */}
<div className="absolute inset-0 bg-red-500/20 rounded-full blur-3xl -z-10" />
</div>
{/* 500 text */}
<div className="relative mb-4">
<h1 className="text-8xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-red-400 via-orange-400 to-yellow-400">
500
</h1>
<div className="absolute inset-0 text-8xl font-bold text-red-500/20 blur-xl">
500
</div>
</div>
<h2 className="text-2xl font-bold text-white mb-3">
Ошибка сервера
</h2>
<p className="text-gray-400 mb-2 max-w-md">
Что-то пошло не так на нашей стороне.
</p>
<p className="text-gray-500 text-sm mb-8 max-w-md">
Мы уже работаем над решением проблемы. Попробуйте обновить страницу.
</p>
{/* Status info */}
<div className="glass rounded-xl p-4 mb-8 max-w-md border border-red-500/20">
<div className="flex items-center gap-2 text-red-400 mb-2">
<ServerCrash className="w-4 h-4" />
<span className="text-sm font-semibold">Internal Server Error</span>
</div>
<p className="text-gray-400 text-sm">
Сервер временно недоступен или перегружен. Обычно это быстро исправляется.
</p>
</div>
{/* Buttons */}
<div className="flex gap-4">
<NeonButton
size="lg"
icon={<RefreshCw className="w-5 h-5" />}
onClick={handleRefresh}
>
Обновить
</NeonButton>
<Link to="/">
<NeonButton size="lg" variant="secondary" icon={<Home className="w-5 h-5" />}>
На главную
</NeonButton>
</Link>
</div>
{/* Decorative sparkles */}
<div className="absolute top-1/4 left-1/4 opacity-20">
<Sparkles className="w-6 h-6 text-red-400 animate-pulse" />
</div>
<div className="absolute bottom-1/3 right-1/4 opacity-20">
<Sparkles className="w-4 h-4 text-orange-400 animate-pulse" style={{ animationDelay: '1s' }} />
</div>
{/* Custom animations */}
<style>{`
@keyframes flicker {
0%, 100% {
transform: translateY(0) scale(1);
opacity: 0.6;
}
25% {
transform: translateY(-3px) scale(1.1);
opacity: 0.8;
}
50% {
transform: translateY(-1px) scale(0.9);
opacity: 0.5;
}
75% {
transform: translateY(-4px) scale(1.05);
opacity: 0.7;
}
}
.animate-flicker {
animation: flicker 0.8s ease-in-out infinite;
}
@keyframes spark {
0%, 100% {
opacity: 0;
transform: scale(0);
}
50% {
opacity: 1;
transform: scale(1);
}
}
.animate-spark {
animation: spark 1.5s ease-in-out infinite;
}
`}</style>
</div>
)
}

View File

@@ -0,0 +1,241 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { NeonButton } from '@/components/ui'
import { Home, Sparkles, Coffee } from 'lucide-react'
export function TeapotPage() {
const [isPoured, setIsPoured] = useState(false)
return (
<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/4 -left-32 w-96 h-96 bg-amber-500/5 rounded-full blur-[100px]" />
<div className="absolute bottom-1/4 -right-32 w-96 h-96 bg-orange-500/5 rounded-full blur-[100px]" />
</div>
{/* Teapot and Cup container */}
<div className="relative mb-8 flex items-start">
{/* Teapot */}
<div
className="relative cursor-pointer transition-transform duration-500 ease-out"
style={{
transform: isPoured ? 'rotate(15deg)' : 'rotate(0deg)',
transformOrigin: '80px 130px'
}}
onClick={() => setIsPoured(!isPoured)}
>
{/* Steam animation */}
<div className={`absolute -top-8 left-1/2 -translate-x-1/2 flex gap-2 transition-opacity duration-500 ${isPoured ? 'opacity-0' : 'opacity-50'}`}>
<div className="w-2 h-8 bg-gradient-to-t from-gray-400/50 to-transparent rounded-full animate-steam" style={{ animationDelay: '0s' }} />
<div className="w-2 h-10 bg-gradient-to-t from-gray-400/50 to-transparent rounded-full animate-steam" style={{ animationDelay: '0.3s' }} />
<div className="w-2 h-6 bg-gradient-to-t from-gray-400/50 to-transparent rounded-full animate-steam" style={{ animationDelay: '0.6s' }} />
</div>
{/* Teapot SVG - expanded viewBox to show full handle */}
<svg width="180" height="140" viewBox="-15 0 175 140" className="drop-shadow-2xl overflow-visible">
{/* Gradients */}
<defs>
<linearGradient id="teapotGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#fde047" />
<stop offset="50%" stopColor="#fbbf24" />
<stop offset="100%" stopColor="#f59e0b" />
</linearGradient>
<linearGradient id="lidGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#fef08a" />
<stop offset="100%" stopColor="#fbbf24" />
</linearGradient>
</defs>
{/* Handle - behind body */}
<path
d="M 25 70 Q -5 70 -5 90 Q -5 110 25 110"
fill="none"
stroke="#f59e0b"
strokeWidth="8"
strokeLinecap="round"
/>
<path
d="M 25 70 Q -5 70 -5 90 Q -5 110 25 110"
fill="none"
stroke="url(#teapotGradient)"
strokeWidth="5"
strokeLinecap="round"
/>
{/* Body */}
<ellipse cx="80" cy="90" rx="55" ry="40" fill="url(#teapotGradient)" stroke="#f59e0b" strokeWidth="3" />
{/* Lid */}
<ellipse cx="80" cy="55" rx="35" ry="10" fill="url(#lidGradient)" stroke="#f59e0b" strokeWidth="2" />
<ellipse cx="80" cy="50" rx="25" ry="7" fill="url(#lidGradient)" stroke="#f59e0b" strokeWidth="2" />
<circle cx="80" cy="42" r="8" fill="#fbbf24" stroke="#f59e0b" strokeWidth="2" />
{/* Spout */}
<path
d="M 135 85 Q 150 75 155 60 Q 158 50 150 45"
fill="none"
stroke="#f59e0b"
strokeWidth="8"
strokeLinecap="round"
/>
<path
d="M 135 85 Q 150 75 155 60 Q 158 50 150 45"
fill="none"
stroke="url(#teapotGradient)"
strokeWidth="5"
strokeLinecap="round"
/>
{/* Face */}
<circle cx="65" cy="85" r="5" fill="#292524" />
<circle cx="95" cy="85" r="5" fill="#292524" />
<circle cx="67" cy="83" r="2" fill="white" />
<circle cx="97" cy="83" r="2" fill="white" />
<path d="M 70 100 Q 80 110 90 100" fill="none" stroke="#292524" strokeWidth="3" strokeLinecap="round" />
{/* Blush */}
<ellipse cx="55" cy="95" rx="8" ry="5" fill="#fca5a5" opacity="0.5" />
<ellipse cx="105" cy="95" rx="8" ry="5" fill="#fca5a5" opacity="0.5" />
</svg>
{/* Glow effect */}
<div className="absolute inset-0 bg-amber-400/20 rounded-full blur-3xl -z-10" />
</div>
{/* Cup - positioned to the right and below */}
<div className="relative ml-[20px] mt-[125px]">
<svg width="100" height="70" viewBox="0 0 95 70" className="drop-shadow-xl">
<defs>
<linearGradient id="cupGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#fef3c7" />
<stop offset="100%" stopColor="#fde68a" />
</linearGradient>
<linearGradient id="teaInCupGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#d97706" />
<stop offset="100%" stopColor="#92400e" />
</linearGradient>
</defs>
{/* Cup body */}
<path
d="M 10 15 L 15 60 Q 20 68 40 68 Q 60 68 65 60 L 70 15 Z"
fill="url(#cupGradient)"
stroke="#f59e0b"
strokeWidth="2"
/>
{/* Cup rim */}
<ellipse cx="40" cy="15" rx="30" ry="8" fill="url(#cupGradient)" stroke="#f59e0b" strokeWidth="2" />
{/* Tea in cup - fills up when pouring */}
<ellipse
cx="40"
cy="20"
rx="25"
ry="6"
fill="url(#teaInCupGradient)"
className={`transition-all duration-1000 ${isPoured ? 'opacity-100' : 'opacity-30'}`}
style={{
transform: isPoured ? 'translateY(0)' : 'translateY(15px)',
transformOrigin: 'center'
}}
/>
{/* Handle */}
<path
d="M 70 25 Q 85 25 85 40 Q 85 55 70 55"
fill="none"
stroke="#f59e0b"
strokeWidth="5"
strokeLinecap="round"
/>
<path
d="M 70 25 Q 85 25 85 40 Q 85 55 70 55"
fill="none"
stroke="url(#cupGradient)"
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
{/* Steam from cup when filled */}
<div className={`absolute -top-4 left-1/2 -translate-x-1/2 flex gap-1 transition-opacity duration-1000 ${isPoured ? 'opacity-60' : 'opacity-0'}`}>
<div className="w-1 h-4 bg-gradient-to-t from-gray-400/40 to-transparent rounded-full animate-steam" style={{ animationDelay: '0.5s' }} />
<div className="w-1 h-5 bg-gradient-to-t from-gray-400/40 to-transparent rounded-full animate-steam" style={{ animationDelay: '0.8s' }} />
<div className="w-1 h-3 bg-gradient-to-t from-gray-400/40 to-transparent rounded-full animate-steam" style={{ animationDelay: '1.1s' }} />
</div>
</div>
</div>
{/* 418 text */}
<div className="relative mb-4">
<h1 className="text-8xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 via-orange-400 to-red-400">
418
</h1>
<div className="absolute inset-0 text-8xl font-bold text-amber-500/20 blur-xl">
418
</div>
</div>
<h2 className="text-2xl font-bold text-white mb-3">
I'm a teapot
</h2>
<p className="text-gray-400 mb-2 max-w-md">
Сервер отказывается варить кофе, потому что он чайник.
</p>
<p className="text-gray-500 text-sm mb-8 max-w-md">
RFC 2324, Hyper Text Coffee Pot Control Protocol
</p>
{/* Fun fact */}
<div className="glass rounded-xl p-4 mb-8 max-w-md border border-amber-500/20">
<div className="flex items-center gap-2 text-amber-400 mb-2">
<Coffee className="w-4 h-4" />
<span className="text-sm font-semibold">Fun fact</span>
</div>
<p className="text-gray-400 text-sm">
Это настоящий HTTP-код ответа из первоапрельской шутки 1998 года.
Нажми на чайник!
</p>
</div>
{/* Button */}
<Link to="/">
<NeonButton size="lg" icon={<Home className="w-5 h-5" />}>
На главную
</NeonButton>
</Link>
{/* Decorative sparkles */}
<div className="absolute top-1/4 left-1/4 opacity-20">
<Sparkles className="w-6 h-6 text-amber-400 animate-pulse" />
</div>
<div className="absolute bottom-1/3 right-1/4 opacity-20">
<Sparkles className="w-4 h-4 text-orange-400 animate-pulse" style={{ animationDelay: '1s' }} />
</div>
{/* Custom animations */}
<style>{`
@keyframes steam {
0% {
transform: translateY(0) scaleX(1);
opacity: 0.5;
}
50% {
transform: translateY(-10px) scaleX(1.2);
opacity: 0.3;
}
100% {
transform: translateY(-20px) scaleX(0.8);
opacity: 0;
}
}
.animate-steam {
animation: steam 2s ease-in-out infinite;
}
`}</style>
</div>
)
}

View File

@@ -3,10 +3,10 @@ import { useParams, useNavigate, Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { usersApi } from '@/api' import { usersApi } from '@/api'
import type { UserProfilePublic } from '@/types' import type { UserProfilePublic } from '@/types'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui' import { GlassCard, StatsCard } from '@/components/ui'
import { import {
User, Trophy, Target, CheckCircle, Flame, User, Trophy, Target, CheckCircle, Flame,
Loader2, ArrowLeft, Calendar Loader2, ArrowLeft, Calendar, Zap
} from 'lucide-react' } from 'lucide-react'
export function UserProfilePage() { export function UserProfilePage() {
@@ -82,8 +82,9 @@ export function UserProfilePage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex justify-center py-12"> <div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" /> <Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка профиля...</p>
</div> </div>
) )
} }
@@ -91,17 +92,17 @@ export function UserProfilePage() {
if (error || !profile) { if (error || !profile) {
return ( return (
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<Card> <GlassCard className="py-12 text-center">
<CardContent 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-16 h-16 text-gray-600 mx-auto mb-4" /> <User className="w-10 h-10 text-gray-600" />
</div>
<h2 className="text-xl font-bold text-white mb-2"> <h2 className="text-xl font-bold text-white mb-2">
{error || 'Пользователь не найден'} {error || 'Пользователь не найден'}
</h2> </h2>
<Link to="/" className="text-primary-400 hover:text-primary-300"> <Link to="/" className="text-neon-400 hover:text-neon-300 transition-colors">
Вернуться на главную Вернуться на главную
</Link> </Link>
</CardContent> </GlassCard>
</Card>
</div> </div>
) )
} }
@@ -111,18 +112,18 @@ export function UserProfilePage() {
{/* Кнопка назад */} {/* Кнопка назад */}
<button <button
onClick={() => navigate(-1)} 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> </button>
{/* Профиль */} {/* Профиль */}
<Card> <GlassCard variant="neon">
<CardContent className="pt-6">
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
{/* Аватар */} {/* Аватар */}
<div className="w-24 h-24 rounded-full overflow-hidden bg-gray-700 flex-shrink-0"> <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_14px_rgba(34,211,238,0.15)]">
{avatarBlobUrl ? ( {avatarBlobUrl ? (
<img <img
src={avatarBlobUrl} src={avatarBlobUrl}
@@ -130,11 +131,16 @@ export function UserProfilePage() {
className="w-full h-full object-cover" 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" /> <User className="w-12 h-12 text-gray-500" />
</div> </div>
)} )}
</div> </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>
{/* Инфо */} {/* Инфо */}
<div> <div>
@@ -142,55 +148,52 @@ export function UserProfilePage() {
{profile.nickname} {profile.nickname}
</h1> </h1>
<div className="flex items-center gap-2 text-gray-400 text-sm"> <div className="flex items-center gap-2 text-gray-400 text-sm">
<Calendar className="w-4 h-4" /> <Calendar className="w-4 h-4 text-accent-400" />
<span>Зарегистрирован {formatDate(profile.created_at)}</span> <span>Зарегистрирован {formatDate(profile.created_at)}</span>
</div> </div>
</div> </div>
</div> </div>
</CardContent> </GlassCard>
</Card>
{/* Статистика */} {/* Статистика */}
<Card> <GlassCard>
<CardHeader> <div className="flex items-center gap-3 mb-6">
<CardTitle className="flex items-center gap-2"> <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-500" /> <Trophy className="w-5 h-5 text-yellow-400" />
Статистика </div>
</CardTitle> <div>
</CardHeader> <h2 className="font-semibold text-white">Статистика</h2>
<CardContent> <p className="text-sm text-gray-400">Достижения игрока</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-900 rounded-lg p-4 text-center"> <StatsCard
<Target className="w-6 h-6 text-blue-400 mx-auto mb-2" /> label="Марафонов"
<div className="text-2xl font-bold text-white"> value={profile.stats.marathons_count}
{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> </div>
<div className="text-sm text-gray-400">Марафонов</div> </GlassCard>
</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>
</div>
</CardContent>
</Card>
</div> </div>
) )
} }

View File

@@ -10,3 +10,5 @@ export { LeaderboardPage } from './LeaderboardPage'
export { ProfilePage } from './ProfilePage' export { ProfilePage } from './ProfilePage'
export { UserProfilePage } from './UserProfilePage' export { UserProfilePage } from './UserProfilePage'
export { NotFoundPage } from './NotFoundPage' export { NotFoundPage } from './NotFoundPage'
export { TeapotPage } from './TeapotPage'
export { ServerErrorPage } from './ServerErrorPage'

View File

@@ -10,6 +10,7 @@ interface AuthState {
isLoading: boolean isLoading: boolean
error: string | null error: string | null
pendingInviteCode: string | null pendingInviteCode: string | null
avatarVersion: number
login: (data: LoginData) => Promise<void> login: (data: LoginData) => Promise<void>
register: (data: RegisterData) => Promise<void> register: (data: RegisterData) => Promise<void>
@@ -18,6 +19,7 @@ interface AuthState {
setPendingInviteCode: (code: string | null) => void setPendingInviteCode: (code: string | null) => void
consumePendingInviteCode: () => string | null consumePendingInviteCode: () => string | null
updateUser: (updates: Partial<User>) => void updateUser: (updates: Partial<User>) => void
bumpAvatarVersion: () => void
} }
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
@@ -29,6 +31,7 @@ export const useAuthStore = create<AuthState>()(
isLoading: false, isLoading: false,
error: null, error: null,
pendingInviteCode: null, pendingInviteCode: null,
avatarVersion: 0,
login: async (data) => { login: async (data) => {
set({ isLoading: true, error: null }) set({ isLoading: true, error: null })
@@ -97,6 +100,10 @@ export const useAuthStore = create<AuthState>()(
set({ user: { ...currentUser, ...updates } }) set({ user: { ...currentUser, ...updates } })
} }
}, },
bumpAvatarVersion: () => {
set({ avatarVersion: get().avatarVersion + 1 })
},
}), }),
{ {
name: 'auth-storage', name: 'auth-storage',

View File

@@ -7,25 +7,91 @@ export default {
theme: { theme: {
extend: { extend: {
colors: { colors: {
primary: { // Base dark colors - slightly warmer tones
50: '#f0f9ff', dark: {
100: '#e0f2fe', 950: '#08090d',
200: '#bae6fd', 900: '#0d0e14',
300: '#7dd3fc', 800: '#14161e',
400: '#38bdf8', 700: '#1c1e28',
500: '#0ea5e9', 600: '#252732',
600: '#0284c7', 500: '#2e313d',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
950: '#082f49',
}, },
// Soft cyan (primary) - gentler on eyes
neon: {
50: '#ecfeff',
100: '#cffafe',
200: '#a5f3fc',
300: '#67e8f9',
400: '#67e8f9',
500: '#22d3ee',
600: '#06b6d4',
700: '#0891b2',
800: '#155e75',
900: '#164e63',
},
// Soft violet accent
accent: {
50: '#f5f3ff',
100: '#ede9fe',
200: '#ddd6fe',
300: '#c4b5fd',
400: '#a78bfa',
500: '#8b5cf6',
600: '#7c3aed',
700: '#6d28d9',
800: '#5b21b6',
900: '#4c1d95',
},
// Soft pink highlight - used sparingly
pink: {
400: '#f472b6',
500: '#ec4899',
600: '#db2777',
},
// Keep primary for backwards compatibility
primary: {
50: '#ecfeff',
100: '#cffafe',
200: '#a5f3fc',
300: '#67e8f9',
400: '#67e8f9',
500: '#22d3ee',
600: '#06b6d4',
700: '#0891b2',
800: '#155e75',
900: '#164e63',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
display: ['Orbitron', 'sans-serif'],
}, },
animation: { animation: {
// Existing
'spin-slow': 'spin 3s linear infinite', 'spin-slow': 'spin 3s linear infinite',
'wheel-spin': 'wheel-spin 4s cubic-bezier(0.17, 0.67, 0.12, 0.99) forwards', 'wheel-spin': 'wheel-spin 4s cubic-bezier(0.17, 0.67, 0.12, 0.99) forwards',
'fade-in': 'fade-in 0.3s ease-out', 'fade-in': 'fade-in 0.3s ease-out forwards',
'slide-up': 'slide-up 0.3s ease-out', 'slide-up': 'slide-up 0.3s ease-out forwards',
// New animations
'glitch': 'glitch 1s linear infinite',
'glitch-1': 'glitch-1 0.5s infinite linear alternate-reverse',
'glitch-2': 'glitch-2 0.5s infinite linear alternate-reverse',
'glow-pulse': 'glow-pulse 2s ease-in-out infinite',
'float': 'float 6s ease-in-out infinite',
'shimmer': 'shimmer 2s linear infinite',
'slide-in-right': 'slide-in-right 0.3s ease-out forwards',
'slide-in-left': 'slide-in-left 0.3s ease-out forwards',
'slide-in-up': 'slide-in-up 0.4s ease-out forwards',
'slide-in-down': 'slide-in-down 0.3s ease-out forwards',
'scale-in': 'scale-in 0.2s ease-out forwards',
'bounce-in': 'bounce-in 0.5s ease-out forwards',
'pulse-neon': 'pulse-neon 2s ease-in-out infinite',
'border-flow': 'border-flow 3s linear infinite',
'typing': 'typing 3.5s steps(40, end), blink-caret 0.75s step-end infinite',
'counter': 'counter 2s ease-out forwards',
'shake': 'shake 0.5s ease-in-out',
'confetti': 'confetti 1s ease-out forwards',
}, },
keyframes: { keyframes: {
'wheel-spin': { 'wheel-spin': {
@@ -40,6 +106,119 @@ export default {
'0%': { opacity: '0', transform: 'translateY(10px)' }, '0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' }, '100%': { opacity: '1', transform: 'translateY(0)' },
}, },
'glitch': {
'0%, 100%': { transform: 'translate(0)' },
'20%': { transform: 'translate(-2px, 2px)' },
'40%': { transform: 'translate(-2px, -2px)' },
'60%': { transform: 'translate(2px, 2px)' },
'80%': { transform: 'translate(2px, -2px)' },
},
'glitch-1': {
'0%': { clipPath: 'inset(20% 0 60% 0)' },
'100%': { clipPath: 'inset(50% 0 30% 0)' },
},
'glitch-2': {
'0%': { clipPath: 'inset(60% 0 20% 0)' },
'100%': { clipPath: 'inset(30% 0 50% 0)' },
},
'glow-pulse': {
'0%, 100%': {
boxShadow: '0 0 6px rgba(34, 211, 238, 0.4), 0 0 12px rgba(34, 211, 238, 0.2)'
},
'50%': {
boxShadow: '0 0 10px rgba(34, 211, 238, 0.5), 0 0 20px rgba(34, 211, 238, 0.3)'
},
},
'float': {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-10px)' },
},
'shimmer': {
'0%': { backgroundPosition: '-200% 0' },
'100%': { backgroundPosition: '200% 0' },
},
'slide-in-right': {
'0%': { opacity: '0', transform: 'translateX(20px)' },
'100%': { opacity: '1', transform: 'translateX(0)' },
},
'slide-in-left': {
'0%': { opacity: '0', transform: 'translateX(-20px)' },
'100%': { opacity: '1', transform: 'translateX(0)' },
},
'slide-in-up': {
'0%': { opacity: '0', transform: 'translateY(30px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
'slide-in-down': {
'0%': { opacity: '0', transform: 'translateY(-20px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
'scale-in': {
'0%': { opacity: '0', transform: 'scale(0.9)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
'bounce-in': {
'0%': { opacity: '0', transform: 'scale(0.3)' },
'50%': { transform: 'scale(1.05)' },
'70%': { transform: 'scale(0.9)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
'pulse-neon': {
'0%, 100%': {
textShadow: '0 0 6px rgba(34, 211, 238, 0.5), 0 0 12px rgba(34, 211, 238, 0.25)'
},
'50%': {
textShadow: '0 0 10px rgba(34, 211, 238, 0.6), 0 0 18px rgba(34, 211, 238, 0.35)'
},
},
'border-flow': {
'0%': { backgroundPosition: '0% 50%' },
'50%': { backgroundPosition: '100% 50%' },
'100%': { backgroundPosition: '0% 50%' },
},
'typing': {
'from': { width: '0' },
'to': { width: '100%' },
},
'blink-caret': {
'from, to': { borderColor: 'transparent' },
'50%': { borderColor: '#22d3ee' },
},
'shake': {
'0%, 100%': { transform: 'translateX(0)' },
'10%, 30%, 50%, 70%, 90%': { transform: 'translateX(-5px)' },
'20%, 40%, 60%, 80%': { transform: 'translateX(5px)' },
},
'confetti': {
'0%': { transform: 'translateY(0) rotate(0deg)', opacity: '1' },
'100%': { transform: 'translateY(100vh) rotate(720deg)', opacity: '0' },
},
},
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
'neon-glow': 'linear-gradient(90deg, #22d3ee, #8b5cf6, #22d3ee)',
'cyber-grid': `
linear-gradient(rgba(34, 211, 238, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(34, 211, 238, 0.02) 1px, transparent 1px)
`,
},
backgroundSize: {
'grid': '50px 50px',
},
boxShadow: {
'neon': '0 0 8px rgba(34, 211, 238, 0.4), 0 0 16px rgba(34, 211, 238, 0.2)',
'neon-lg': '0 0 12px rgba(34, 211, 238, 0.5), 0 0 24px rgba(34, 211, 238, 0.3)',
'neon-purple': '0 0 8px rgba(139, 92, 246, 0.4), 0 0 16px rgba(139, 92, 246, 0.2)',
'neon-pink': '0 0 8px rgba(244, 114, 182, 0.4), 0 0 16px rgba(244, 114, 182, 0.2)',
'inner-glow': 'inset 0 0 20px rgba(34, 211, 238, 0.06)',
'glass': '0 8px 32px 0 rgba(0, 0, 0, 0.37)',
},
backdropBlur: {
'xs': '2px',
},
transitionDuration: {
'400': '400ms',
}, },
}, },
}, },

13
status-service/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Run the application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]

109
status-service/main.py Normal file
View File

@@ -0,0 +1,109 @@
import os
import asyncio
from datetime import datetime, timedelta
from typing import Optional
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from monitors import ServiceMonitor, ServiceStatus
# Configuration
BACKEND_URL = os.getenv("BACKEND_URL", "http://backend:8000")
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://frontend:80")
BOT_URL = os.getenv("BOT_URL", "http://bot:8080")
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "30"))
# Initialize monitor
monitor = ServiceMonitor()
# Background task reference
background_task: Optional[asyncio.Task] = None
async def periodic_health_check():
"""Background task to check services periodically"""
while True:
await monitor.check_all_services(
backend_url=BACKEND_URL,
frontend_url=FRONTEND_URL,
bot_url=BOT_URL
)
await asyncio.sleep(CHECK_INTERVAL)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup and shutdown events"""
global background_task
# Start background health checks
background_task = asyncio.create_task(periodic_health_check())
yield
# Cancel background task on shutdown
if background_task:
background_task.cancel()
try:
await background_task
except asyncio.CancelledError:
pass
app = FastAPI(
title="Status Monitor",
description="Service health monitoring",
lifespan=lifespan
)
templates = Jinja2Templates(directory="templates")
@app.get("/", response_class=HTMLResponse)
async def status_page(request: Request):
"""Main status page"""
services = monitor.get_all_statuses()
overall_status = monitor.get_overall_status()
return templates.TemplateResponse(
"index.html",
{
"request": request,
"services": services,
"overall_status": overall_status,
"last_check": monitor.last_check,
"check_interval": CHECK_INTERVAL
}
)
@app.get("/api/status")
async def api_status():
"""API endpoint for service statuses"""
services = monitor.get_all_statuses()
overall_status = monitor.get_overall_status()
return {
"overall_status": overall_status,
"services": {name: status.to_dict() for name, status in services.items()},
"last_check": monitor.last_check.isoformat() if monitor.last_check else None,
"check_interval_seconds": CHECK_INTERVAL
}
@app.get("/api/health")
async def health():
"""Health check for this service"""
return {"status": "ok", "service": "status-monitor"}
@app.post("/api/refresh")
async def refresh_status():
"""Force refresh all service statuses"""
await monitor.check_all_services(
backend_url=BACKEND_URL,
frontend_url=FRONTEND_URL,
bot_url=BOT_URL
)
return {"status": "refreshed"}

227
status-service/monitors.py Normal file
View File

@@ -0,0 +1,227 @@
import asyncio
from datetime import datetime, timedelta
from dataclasses import dataclass, field
from typing import Optional
from enum import Enum
import httpx
class Status(str, Enum):
OPERATIONAL = "operational"
DEGRADED = "degraded"
DOWN = "down"
UNKNOWN = "unknown"
@dataclass
class ServiceStatus:
name: str
display_name: str
status: Status = Status.UNKNOWN
latency_ms: Optional[float] = None
last_check: Optional[datetime] = None
last_incident: Optional[datetime] = None
uptime_percent: float = 100.0
message: Optional[str] = None
version: Optional[str] = None
# For uptime calculation
total_checks: int = 0
successful_checks: int = 0
def to_dict(self) -> dict:
return {
"name": self.name,
"display_name": self.display_name,
"status": self.status.value,
"latency_ms": round(self.latency_ms, 2) if self.latency_ms else None,
"last_check": self.last_check.isoformat() if self.last_check else None,
"last_incident": self.last_incident.isoformat() if self.last_incident else None,
"uptime_percent": round(self.uptime_percent, 2),
"message": self.message,
"version": self.version
}
def update_uptime(self, is_success: bool):
self.total_checks += 1
if is_success:
self.successful_checks += 1
if self.total_checks > 0:
self.uptime_percent = (self.successful_checks / self.total_checks) * 100
class ServiceMonitor:
def __init__(self):
self.services: dict[str, ServiceStatus] = {
"backend": ServiceStatus(
name="backend",
display_name="Backend API"
),
"database": ServiceStatus(
name="database",
display_name="Database"
),
"frontend": ServiceStatus(
name="frontend",
display_name="Frontend"
),
"bot": ServiceStatus(
name="bot",
display_name="Telegram Bot"
)
}
self.last_check: Optional[datetime] = None
async def check_backend(self, url: str) -> tuple[Status, Optional[float], Optional[str], Optional[str]]:
"""Check backend API health"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
start = datetime.now()
response = await client.get(f"{url}/health")
latency = (datetime.now() - start).total_seconds() * 1000
if response.status_code == 200:
data = response.json()
return Status.OPERATIONAL, latency, None, data.get("version")
else:
return Status.DEGRADED, latency, f"HTTP {response.status_code}", None
except httpx.TimeoutException:
return Status.DOWN, None, "Timeout", None
except Exception as e:
return Status.DOWN, None, str(e)[:100], None
async def check_database(self, backend_url: str) -> tuple[Status, Optional[float], Optional[str]]:
"""Check database through backend"""
# We check database indirectly - if backend is up, DB is likely up
# Could add a specific /health/db endpoint to backend later
try:
async with httpx.AsyncClient(timeout=10.0) as client:
start = datetime.now()
response = await client.get(f"{backend_url}/health")
latency = (datetime.now() - start).total_seconds() * 1000
if response.status_code == 200:
return Status.OPERATIONAL, latency, None
else:
return Status.DOWN, latency, "Backend reports unhealthy"
except Exception as e:
return Status.DOWN, None, "Cannot reach backend"
async def check_frontend(self, url: str) -> tuple[Status, Optional[float], Optional[str]]:
"""Check frontend availability"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
start = datetime.now()
response = await client.get(url)
latency = (datetime.now() - start).total_seconds() * 1000
if response.status_code == 200:
return Status.OPERATIONAL, latency, None
else:
return Status.DEGRADED, latency, f"HTTP {response.status_code}"
except httpx.TimeoutException:
return Status.DOWN, None, "Timeout"
except Exception as e:
return Status.DOWN, None, str(e)[:100]
async def check_bot(self, url: str) -> tuple[Status, Optional[float], Optional[str]]:
"""Check Telegram bot health"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
start = datetime.now()
response = await client.get(f"{url}/health")
latency = (datetime.now() - start).total_seconds() * 1000
if response.status_code == 200:
return Status.OPERATIONAL, latency, None
else:
return Status.DEGRADED, latency, f"HTTP {response.status_code}"
except httpx.TimeoutException:
return Status.DOWN, None, "Timeout"
except Exception as e:
return Status.DOWN, None, str(e)[:100]
async def check_all_services(self, backend_url: str, frontend_url: str, bot_url: str):
"""Check all services concurrently"""
now = datetime.now()
# Run all checks concurrently
results = await asyncio.gather(
self.check_backend(backend_url),
self.check_database(backend_url),
self.check_frontend(frontend_url),
self.check_bot(bot_url),
return_exceptions=True
)
# Process backend result
if not isinstance(results[0], Exception):
status, latency, message, version = results[0]
svc = self.services["backend"]
was_down = svc.status == Status.DOWN
svc.status = status
svc.latency_ms = latency
svc.message = message
svc.version = version
svc.last_check = now
svc.update_uptime(status == Status.OPERATIONAL)
if status != Status.OPERATIONAL and not was_down:
svc.last_incident = now
# Process database result
if not isinstance(results[1], Exception):
status, latency, message = results[1]
svc = self.services["database"]
was_down = svc.status == Status.DOWN
svc.status = status
svc.latency_ms = latency
svc.message = message
svc.last_check = now
svc.update_uptime(status == Status.OPERATIONAL)
if status != Status.OPERATIONAL and not was_down:
svc.last_incident = now
# Process frontend result
if not isinstance(results[2], Exception):
status, latency, message = results[2]
svc = self.services["frontend"]
was_down = svc.status == Status.DOWN
svc.status = status
svc.latency_ms = latency
svc.message = message
svc.last_check = now
svc.update_uptime(status == Status.OPERATIONAL)
if status != Status.OPERATIONAL and not was_down:
svc.last_incident = now
# Process bot result
if not isinstance(results[3], Exception):
status, latency, message = results[3]
svc = self.services["bot"]
was_down = svc.status == Status.DOWN
svc.status = status
svc.latency_ms = latency
svc.message = message
svc.last_check = now
svc.update_uptime(status == Status.OPERATIONAL)
if status != Status.OPERATIONAL and not was_down:
svc.last_incident = now
self.last_check = now
def get_all_statuses(self) -> dict[str, ServiceStatus]:
return self.services
def get_overall_status(self) -> Status:
"""Get overall system status based on all services"""
statuses = [svc.status for svc in self.services.values()]
if all(s == Status.OPERATIONAL for s in statuses):
return Status.OPERATIONAL
elif any(s == Status.DOWN for s in statuses):
return Status.DOWN
elif any(s == Status.DEGRADED for s in statuses):
return Status.DEGRADED
else:
return Status.UNKNOWN

View File

@@ -0,0 +1,5 @@
fastapi==0.109.0
uvicorn==0.27.0
httpx==0.26.0
jinja2==3.1.3
python-dotenv==1.0.0

View File

@@ -0,0 +1,386 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>System Status</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%);
min-height: 100vh;
color: #e0e0e0;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 40px 20px;
}
header {
text-align: center;
margin-bottom: 40px;
}
h1 {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 10px;
background: linear-gradient(135deg, #00d4ff, #a855f7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.overall-status {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 12px 24px;
border-radius: 50px;
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 10px;
}
.overall-status.operational {
background: rgba(34, 197, 94, 0.15);
border: 1px solid rgba(34, 197, 94, 0.3);
color: #22c55e;
box-shadow: 0 0 20px rgba(34, 197, 94, 0.2);
}
.overall-status.degraded {
background: rgba(250, 204, 21, 0.15);
border: 1px solid rgba(250, 204, 21, 0.3);
color: #facc15;
box-shadow: 0 0 20px rgba(250, 204, 21, 0.2);
}
.overall-status.down {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #ef4444;
box-shadow: 0 0 20px rgba(239, 68, 68, 0.2);
}
.overall-status.unknown {
background: rgba(148, 163, 184, 0.15);
border: 1px solid rgba(148, 163, 184, 0.3);
color: #94a3b8;
}
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
animation: pulse 2s infinite;
}
.status-dot.operational { background: #22c55e; }
.status-dot.degraded { background: #facc15; }
.status-dot.down { background: #ef4444; }
.status-dot.unknown { background: #94a3b8; }
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.7; transform: scale(1.1); }
}
.last-update {
color: #64748b;
font-size: 0.9rem;
}
.services-grid {
display: grid;
gap: 16px;
}
.service-card {
background: rgba(30, 41, 59, 0.5);
border: 1px solid rgba(100, 116, 139, 0.2);
border-radius: 16px;
padding: 24px;
backdrop-filter: blur(10px);
transition: all 0.3s ease;
}
.service-card:hover {
border-color: rgba(0, 212, 255, 0.3);
box-shadow: 0 0 30px rgba(0, 212, 255, 0.1);
}
.service-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.service-name {
font-size: 1.25rem;
font-weight: 600;
color: #f1f5f9;
}
.service-status {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.service-status.operational {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.service-status.degraded {
background: rgba(250, 204, 21, 0.15);
color: #facc15;
}
.service-status.down {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.service-status.unknown {
background: rgba(148, 163, 184, 0.15);
color: #94a3b8;
}
.service-status .dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.service-status.operational .dot { background: #22c55e; }
.service-status.degraded .dot { background: #facc15; }
.service-status.down .dot { background: #ef4444; }
.service-status.unknown .dot { background: #94a3b8; }
.service-metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.metric {
background: rgba(15, 23, 42, 0.5);
padding: 12px;
border-radius: 10px;
}
.metric-label {
font-size: 0.75rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.metric-value {
font-size: 1.1rem;
font-weight: 600;
color: #e2e8f0;
}
.metric-value.good { color: #22c55e; }
.metric-value.warning { color: #facc15; }
.metric-value.bad { color: #ef4444; }
.service-message {
margin-top: 12px;
padding: 10px 14px;
background: rgba(239, 68, 68, 0.1);
border-left: 3px solid #ef4444;
border-radius: 0 8px 8px 0;
font-size: 0.9rem;
color: #fca5a5;
}
.refresh-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(168, 85, 247, 0.2));
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 10px;
color: #00d4ff;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 30px;
}
.refresh-btn:hover {
background: linear-gradient(135deg, rgba(0, 212, 255, 0.3), rgba(168, 85, 247, 0.3));
box-shadow: 0 0 20px rgba(0, 212, 255, 0.3);
transform: translateY(-2px);
}
.refresh-btn:active {
transform: translateY(0);
}
.refresh-btn.loading svg {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
footer {
text-align: center;
margin-top: 50px;
padding-top: 30px;
border-top: 1px solid rgba(100, 116, 139, 0.2);
color: #64748b;
font-size: 0.85rem;
}
footer a {
color: #00d4ff;
text-decoration: none;
}
footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>System Status</h1>
<div class="overall-status {{ overall_status.value }}">
<span class="status-dot {{ overall_status.value }}"></span>
{% if overall_status.value == 'operational' %}
All Systems Operational
{% elif overall_status.value == 'degraded' %}
Partial System Outage
{% elif overall_status.value == 'down' %}
Major System Outage
{% else %}
Status Unknown
{% endif %}
</div>
<p class="last-update">
{% if last_check %}
Last updated: {{ last_check.strftime('%d.%m.%Y %H:%M:%S') }}
{% else %}
Checking services...
{% endif %}
&bull; Auto-refresh every {{ check_interval }}s
</p>
</header>
<div class="services-grid">
{% for name, service in services.items() %}
<div class="service-card">
<div class="service-header">
<span class="service-name">{{ service.display_name }}</span>
<span class="service-status {{ service.status.value }}">
<span class="dot"></span>
{% if service.status.value == 'operational' %}
Operational
{% elif service.status.value == 'degraded' %}
Degraded
{% elif service.status.value == 'down' %}
Down
{% else %}
Unknown
{% endif %}
</span>
</div>
<div class="service-metrics">
<div class="metric">
<div class="metric-label">Latency</div>
<div class="metric-value {% if service.latency_ms and service.latency_ms < 200 %}good{% elif service.latency_ms and service.latency_ms < 500 %}warning{% elif service.latency_ms %}bad{% endif %}">
{% if service.latency_ms %}
{{ "%.0f"|format(service.latency_ms) }} ms
{% else %}
{% endif %}
</div>
</div>
<div class="metric">
<div class="metric-label">Uptime</div>
<div class="metric-value {% if service.uptime_percent >= 99 %}good{% elif service.uptime_percent >= 95 %}warning{% else %}bad{% endif %}">
{{ "%.1f"|format(service.uptime_percent) }}%
</div>
</div>
{% if service.version %}
<div class="metric">
<div class="metric-label">Version</div>
<div class="metric-value">{{ service.version }}</div>
</div>
{% endif %}
{% if service.last_incident %}
<div class="metric">
<div class="metric-label">Last Incident</div>
<div class="metric-value warning">{{ service.last_incident.strftime('%d.%m %H:%M') }}</div>
</div>
{% endif %}
</div>
{% if service.message %}
<div class="service-message">{{ service.message }}</div>
{% endif %}
</div>
{% endfor %}
</div>
<center>
<button class="refresh-btn" onclick="refreshStatus(this)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M23 4v6h-6M1 20v-6h6"/>
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>
</svg>
Refresh
</button>
</center>
<footer>
<p>Game Marathon Status Monitor</p>
</footer>
</div>
<script>
async function refreshStatus(btn) {
btn.classList.add('loading');
btn.disabled = true;
try {
await fetch('/api/refresh', { method: 'POST' });
window.location.reload();
} catch (e) {
console.error('Refresh failed:', e);
btn.classList.remove('loading');
btn.disabled = false;
}
}
// Auto-refresh page
setTimeout(() => {
window.location.reload();
}, {{ check_interval }} * 1000);
</script>
</body>
</html>