Compare commits
9 Commits
11f7b59471
...
967176fab8
| Author | SHA1 | Date | |
|---|---|---|---|
| 967176fab8 | |||
| f371178518 | |||
| 3920a9bf8c | |||
| 790b2d6083 | |||
| 675a0fea0c | |||
| 0b3837b08e | |||
| 7e7cdbcd76 | |||
| debdd66458 | |||
| 332491454d |
389
REDESIGN_PLAN.md
Normal file
389
REDESIGN_PLAN.md
Normal 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
434
auth-pages-backup.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ from app.schemas import (
|
||||
ChallengePreview,
|
||||
ChallengesPreviewResponse,
|
||||
ChallengesSaveRequest,
|
||||
ChallengesGenerateRequest,
|
||||
)
|
||||
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)
|
||||
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."""
|
||||
# Check marathon
|
||||
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)
|
||||
|
||||
# Get only APPROVED games
|
||||
result = await db.execute(
|
||||
select(Game).where(
|
||||
query = select(Game).where(
|
||||
Game.marathon_id == marathon_id,
|
||||
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()
|
||||
|
||||
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 = []
|
||||
game_map = {}
|
||||
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(
|
||||
select(Challenge.id).where(Challenge.game_id == game.id).limit(1)
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.core.config import settings
|
||||
from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType
|
||||
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
|
||||
from app.services.storage import storage_service
|
||||
from app.services.telegram_notifier import telegram_notifier
|
||||
|
||||
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:
|
||||
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.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.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
|
||||
game = await get_game_or_404(db, game_id)
|
||||
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:
|
||||
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
|
||||
|
||||
# Log activity
|
||||
@@ -316,6 +338,12 @@ async def reject_game(game_id: int, current_user: CurrentUser, db: DbSession):
|
||||
await db.commit()
|
||||
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
|
||||
game = await get_game_or_404(db, game_id)
|
||||
challenges_count = await db.scalar(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from datetime import timedelta
|
||||
import secrets
|
||||
import string
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -10,7 +11,7 @@ from app.api.deps import (
|
||||
get_participant,
|
||||
)
|
||||
from app.models import (
|
||||
Marathon, Participant, MarathonStatus, Game, GameStatus,
|
||||
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
|
||||
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
|
||||
)
|
||||
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"))
|
||||
.outerjoin(Participant)
|
||||
.options(selectinload(Marathon.creator))
|
||||
.where(Marathon.invite_code == invite_code)
|
||||
.where(func.upper(Marathon.invite_code) == invite_code.upper())
|
||||
.group_by(Marathon.id)
|
||||
)
|
||||
row = result.first()
|
||||
@@ -62,7 +63,9 @@ async def get_marathon_by_code(invite_code: str, db: DbSession):
|
||||
|
||||
|
||||
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:
|
||||
@@ -272,15 +275,33 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
|
||||
if marathon.status != MarathonStatus.PREPARING.value:
|
||||
raise HTTPException(status_code=400, detail="Marathon is not in preparing state")
|
||||
|
||||
# Check if there are approved games with challenges
|
||||
games_count = await db.scalar(
|
||||
select(func.count()).select_from(Game).where(
|
||||
# Check if there are approved games
|
||||
games_result = await db.execute(
|
||||
select(Game).where(
|
||||
Game.marathon_id == marathon_id,
|
||||
Game.status == GameStatus.APPROVED.value,
|
||||
)
|
||||
)
|
||||
if games_count == 0:
|
||||
raise HTTPException(status_code=400, detail="Add and approve at least one game before starting")
|
||||
approved_games = games_result.scalars().all()
|
||||
|
||||
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
|
||||
|
||||
@@ -332,7 +353,7 @@ async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSes
|
||||
@router.post("/join", response_model=MarathonResponse)
|
||||
async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSession):
|
||||
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()
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ from app.schemas.challenge import (
|
||||
ChallengesPreviewResponse,
|
||||
ChallengeSaveItem,
|
||||
ChallengesSaveRequest,
|
||||
ChallengesGenerateRequest,
|
||||
)
|
||||
from app.schemas.assignment import (
|
||||
CompleteAssignment,
|
||||
@@ -118,6 +119,7 @@ __all__ = [
|
||||
"ChallengesPreviewResponse",
|
||||
"ChallengeSaveItem",
|
||||
"ChallengesSaveRequest",
|
||||
"ChallengesGenerateRequest",
|
||||
# Assignment
|
||||
"CompleteAssignment",
|
||||
"AssignmentResponse",
|
||||
|
||||
@@ -88,3 +88,8 @@ class ChallengeSaveItem(BaseModel):
|
||||
class ChallengesSaveRequest(BaseModel):
|
||||
"""Request to save previewed challenges"""
|
||||
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
|
||||
|
||||
@@ -244,6 +244,38 @@ class TelegramNotifier:
|
||||
)
|
||||
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
|
||||
telegram_notifier = TelegramNotifier()
|
||||
|
||||
35
bot/main.py
35
bot/main.py
@@ -5,6 +5,7 @@ import sys
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiohttp import web
|
||||
|
||||
from config import settings
|
||||
from handlers import start, marathons, link
|
||||
@@ -23,14 +24,41 @@ logger = logging.getLogger(__name__)
|
||||
# Set aiogram logging level
|
||||
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():
|
||||
global bot_running
|
||||
|
||||
logger.info("="*50)
|
||||
logger.info("Starting Game Marathon Bot...")
|
||||
logger.info(f"API_URL: {settings.API_URL}")
|
||||
logger.info(f"BOT_TOKEN: {settings.TELEGRAM_BOT_TOKEN[:20]}...")
|
||||
logger.info("="*50)
|
||||
|
||||
# Start health check server
|
||||
health_runner = await start_health_server()
|
||||
|
||||
bot = Bot(
|
||||
token=settings.TELEGRAM_BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||
@@ -54,11 +82,18 @@ async def main():
|
||||
dp.include_router(marathons.router)
|
||||
logger.info("Routers registered: start, link, marathons")
|
||||
|
||||
# Mark bot as running
|
||||
bot_running = True
|
||||
|
||||
# Start polling
|
||||
logger.info("Deleting webhook and starting polling...")
|
||||
await bot.delete_webhook(drop_pending_updates=True)
|
||||
logger.info("Polling started! Waiting for messages...")
|
||||
try:
|
||||
await dp.start_polling(bot)
|
||||
finally:
|
||||
bot_running = False
|
||||
await health_runner.cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -85,5 +85,23 @@ services:
|
||||
- backend
|
||||
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:
|
||||
postgres_data:
|
||||
|
||||
@@ -20,6 +20,8 @@ import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage'
|
||||
import { ProfilePage } from '@/pages/ProfilePage'
|
||||
import { UserProfilePage } from '@/pages/UserProfilePage'
|
||||
import { NotFoundPage } from '@/pages/NotFoundPage'
|
||||
import { TeapotPage } from '@/pages/TeapotPage'
|
||||
import { ServerErrorPage } from '@/pages/ServerErrorPage'
|
||||
|
||||
// Protected route wrapper
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
@@ -148,6 +150,15 @@ function App() {
|
||||
|
||||
<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 */}
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -22,11 +22,28 @@ client.interceptors.request.use((config) => {
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: AxiosError<{ detail: string }>) => {
|
||||
// Unauthorized - redirect to login
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
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)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -79,8 +79,9 @@ export const gamesApi = {
|
||||
await client.delete(`/challenges/${id}`)
|
||||
},
|
||||
|
||||
previewChallenges: async (marathonId: number): Promise<ChallengesPreviewResponse> => {
|
||||
const response = await client.post<ChallengesPreviewResponse>(`/marathons/${marathonId}/preview-challenges`)
|
||||
previewChallenges: async (marathonId: number, gameIds?: number[]): Promise<ChallengesPreviewResponse> => {
|
||||
const data = gameIds?.length ? { game_ids: gameIds } : undefined
|
||||
const response = await client.post<ChallengesPreviewResponse>(`/marathons/${marathonId}/preview-challenges`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
|
||||
@@ -41,8 +41,9 @@ export const usersApi = {
|
||||
},
|
||||
|
||||
// Получить аватар пользователя как blob URL
|
||||
getAvatarUrl: async (userId: number): Promise<string> => {
|
||||
const response = await client.get(`/users/${userId}/avatar`, {
|
||||
getAvatarUrl: async (userId: number, bustCache = false): Promise<string> => {
|
||||
const cacheBuster = bustCache ? `?t=${Date.now()}` : ''
|
||||
const response = await client.get(`/users/${userId}/avatar${cacheBuster}`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
return URL.createObjectURL(response.data)
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
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 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 {
|
||||
formatRelativeTime,
|
||||
getActivityIcon,
|
||||
getActivityColor,
|
||||
getActivityBgClass,
|
||||
isEventActivity,
|
||||
formatActivityMessage,
|
||||
} from '@/utils/activity'
|
||||
@@ -100,52 +99,66 @@ export const ActivityFeed = forwardRef<ActivityFeedRef, ActivityFeedProps>(
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`bg-gray-800/50 rounded-xl border border-gray-700/50 p-4 flex flex-col ${className}`}>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Bell className="w-5 h-5 text-primary-500" />
|
||||
<h3 className="font-medium text-white">Активность</h3>
|
||||
<div className={`glass rounded-2xl border border-dark-600 flex flex-col ${className}`}>
|
||||
<div className="flex items-center gap-3 px-5 py-4 border-b border-dark-600">
|
||||
<div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center">
|
||||
<ActivityIcon className="w-4 h-4 text-neon-400" />
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
|
||||
<h3 className="font-semibold text-white">Активность</h3>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
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 */}
|
||||
<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 gap-2">
|
||||
<Bell className="w-5 h-5 text-primary-500" />
|
||||
<h3 className="font-medium text-white">Активность</h3>
|
||||
<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-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center">
|
||||
<Zap className="w-4 h-4 text-neon-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">Активность</h3>
|
||||
{total > 0 && (
|
||||
<span className="text-xs text-gray-500">{total}</span>
|
||||
<p className="text-xs text-gray-500">{total} событий</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-2 h-2 rounded-full bg-neon-500 animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* Activity list */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar">
|
||||
{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 className="divide-y divide-gray-700/30">
|
||||
{activities.map((activity) => (
|
||||
<ActivityItem key={activity.id} activity={activity} />
|
||||
<div className="divide-y divide-dark-600/50">
|
||||
{activities.map((activity, index) => (
|
||||
<ActivityItem
|
||||
key={activity.id}
|
||||
activity={activity}
|
||||
isNew={index === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more button */}
|
||||
{hasMore && (
|
||||
<div className="p-3 border-t border-gray-700/30">
|
||||
<div className="p-4 border-t border-dark-600/50">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
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 ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
@@ -168,13 +181,13 @@ ActivityFeed.displayName = 'ActivityFeed'
|
||||
|
||||
interface ActivityItemProps {
|
||||
activity: Activity
|
||||
isNew?: boolean
|
||||
}
|
||||
|
||||
function ActivityItem({ activity }: ActivityItemProps) {
|
||||
function ActivityItem({ activity, isNew }: ActivityItemProps) {
|
||||
const navigate = useNavigate()
|
||||
const Icon = getActivityIcon(activity.type)
|
||||
const iconColor = getActivityColor(activity.type)
|
||||
const bgClass = getActivityBgClass(activity.type)
|
||||
const isEvent = isEventActivity(activity.type)
|
||||
const { title, details, extra } = formatActivityMessage(activity)
|
||||
|
||||
@@ -187,21 +200,58 @@ function ActivityItem({ activity }: ActivityItemProps) {
|
||||
? activityData.dispute_status
|
||||
: 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) {
|
||||
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="flex items-center gap-2 mb-1">
|
||||
<Icon className={`w-4 h-4 ${iconColor}`} />
|
||||
<span className={`text-sm font-medium ${activity.type === 'event_start' ? 'text-yellow-400' : 'text-gray-400'}`}>
|
||||
<div className={`
|
||||
px-5 py-4 border-l-2 transition-colors
|
||||
${accent.border} ${accent.bg}
|
||||
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}
|
||||
</span>
|
||||
</div>
|
||||
{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}
|
||||
</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)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,39 +259,57 @@ function ActivityItem({ activity }: ActivityItemProps) {
|
||||
}
|
||||
|
||||
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">
|
||||
{/* Avatar */}
|
||||
<div className="flex-shrink-0">
|
||||
<Link to={`/users/${activity.user.id}`} className="flex-shrink-0 relative" onClick={(e) => e.stopPropagation()}>
|
||||
<UserAvatar
|
||||
userId={activity.user.id}
|
||||
hasAvatar={!!activity.user.avatar_url}
|
||||
nickname={activity.user.nickname}
|
||||
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>
|
||||
</Link>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<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}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
</Link>
|
||||
<span className="text-xs text-gray-600">
|
||||
{formatRelativeTime(activity.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<Icon className={`w-3.5 h-3.5 flex-shrink-0 ${iconColor}`} />
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<span className="text-sm text-gray-300">{title}</span>
|
||||
</div>
|
||||
{details && (
|
||||
<div className="text-sm text-gray-400 mt-1">
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{details}
|
||||
</div>
|
||||
)}
|
||||
{extra && (
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
{extra}
|
||||
</div>
|
||||
)}
|
||||
@@ -250,19 +318,19 @@ function ActivityItem({ activity }: ActivityItemProps) {
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<button
|
||||
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" />
|
||||
Детали
|
||||
</button>
|
||||
{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" />
|
||||
Оспаривается
|
||||
</span>
|
||||
)}
|
||||
{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" />
|
||||
Отклонено
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { EVENT_INFO } from '@/types'
|
||||
|
||||
@@ -17,13 +17,55 @@ const EVENT_ICONS: Record<EventType, React.ReactNode> = {
|
||||
game_choice: <Gamepad2 className="w-5 h-5" />,
|
||||
}
|
||||
|
||||
const EVENT_COLORS: Record<EventType, string> = {
|
||||
golden_hour: 'from-yellow-500/20 to-yellow-600/20 border-yellow-500/50 text-yellow-400',
|
||||
common_enemy: 'from-red-500/20 to-red-600/20 border-red-500/50 text-red-400',
|
||||
double_risk: 'from-purple-500/20 to-purple-600/20 border-purple-500/50 text-purple-400',
|
||||
jackpot: 'from-green-500/20 to-green-600/20 border-green-500/50 text-green-400',
|
||||
swap: 'from-blue-500/20 to-blue-600/20 border-blue-500/50 text-blue-400',
|
||||
game_choice: 'from-orange-500/20 to-orange-600/20 border-orange-500/50 text-orange-400',
|
||||
const EVENT_COLORS: Record<EventType, {
|
||||
gradient: string
|
||||
border: string
|
||||
text: string
|
||||
glow: string
|
||||
iconBg: string
|
||||
}> = {
|
||||
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 {
|
||||
@@ -68,42 +110,53 @@ export function EventBanner({ activeEvent, onRefresh }: EventBannerProps) {
|
||||
const event = activeEvent.event
|
||||
const info = EVENT_INFO[event.type]
|
||||
const icon = EVENT_ICONS[event.type]
|
||||
const colorClass = EVENT_COLORS[event.type]
|
||||
const colors = EVENT_COLORS[event.type]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
relative overflow-hidden rounded-xl border p-4
|
||||
bg-gradient-to-r ${colorClass}
|
||||
relative overflow-hidden rounded-2xl border p-5
|
||||
glass ${colors.border} ${colors.glow}
|
||||
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="relative flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-white/10">
|
||||
{/* Grid pattern */}
|
||||
<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="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}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg">{info.name}</h3>
|
||||
<p className="text-sm opacity-80">{info.description}</p>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<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 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 && (
|
||||
<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" />
|
||||
{formatTime(timeRemaining)}
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Play, Square } from 'lucide-react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Play, Square, Sparkles } from 'lucide-react'
|
||||
import { NeonButton } from '@/components/ui'
|
||||
import { eventsApi } from '@/api'
|
||||
import type { ActiveEvent, EventType, Challenge } from '@/types'
|
||||
import { EVENT_INFO } from '@/types'
|
||||
@@ -24,12 +24,21 @@ const EVENT_TYPES: EventType[] = [
|
||||
]
|
||||
|
||||
const EVENT_ICONS: Record<EventType, React.ReactNode> = {
|
||||
golden_hour: <Zap className="w-4 h-4" />,
|
||||
common_enemy: <Users className="w-4 h-4" />,
|
||||
double_risk: <Shield className="w-4 h-4" />,
|
||||
jackpot: <Gift className="w-4 h-4" />,
|
||||
swap: <ArrowLeftRight className="w-4 h-4" />,
|
||||
game_choice: <Gamepad2 className="w-4 h-4" />,
|
||||
golden_hour: <Zap className="w-5 h-5" />,
|
||||
common_enemy: <Users className="w-5 h-5" />,
|
||||
double_risk: <Shield className="w-5 h-5" />,
|
||||
jackpot: <Gift className="w-5 h-5" />,
|
||||
swap: <ArrowLeftRight className="w-5 h-5" />,
|
||||
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)
|
||||
@@ -107,54 +116,81 @@ export function EventControl({
|
||||
}
|
||||
|
||||
if (activeEvent.event) {
|
||||
const colors = EVENT_COLORS[activeEvent.event.type]
|
||||
return (
|
||||
<div className="p-4 bg-gray-800 rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`glass rounded-xl p-4 border ${colors.selected}`}>
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg bg-white/10 ${colors.icon}`}>
|
||||
{EVENT_ICONS[activeEvent.event.type]}
|
||||
<span className="font-medium">
|
||||
Активно: {EVENT_INFO[activeEvent.event.type].name}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
<div>
|
||||
<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"
|
||||
onClick={handleStop}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-gray-800 rounded-xl space-y-4">
|
||||
<h3 className="font-bold text-white">Запустить событие</h3>
|
||||
<div className="glass rounded-xl p-5 space-y-5">
|
||||
<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">
|
||||
{EVENT_TYPES.map((type) => (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{EVENT_TYPES.map((type) => {
|
||||
const colors = EVENT_COLORS[type]
|
||||
const isSelected = selectedType === type
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleTypeChange(type)}
|
||||
className={`
|
||||
p-3 rounded-lg border-2 transition-all text-left
|
||||
${selectedType === type
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-gray-700 hover:border-gray-600'}
|
||||
relative p-4 rounded-xl border-2 transition-all duration-300 text-left
|
||||
${isSelected
|
||||
? `${colors.selected} shadow-lg`
|
||||
: '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]}
|
||||
<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>
|
||||
<p className="text-xs text-gray-400 line-clamp-2">
|
||||
<p className="text-xs text-gray-500 line-clamp-2">
|
||||
{EVENT_INFO[type].description}
|
||||
</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>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Duration setting */}
|
||||
@@ -170,9 +206,9 @@ export function EventControl({
|
||||
min={1}
|
||||
max={480}
|
||||
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]} мин)
|
||||
</p>
|
||||
</div>
|
||||
@@ -186,7 +222,7 @@ export function EventControl({
|
||||
<select
|
||||
value={selectedChallengeId || ''}
|
||||
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>
|
||||
{challenges.map((c) => (
|
||||
@@ -198,15 +234,15 @@ export function EventControl({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
<NeonButton
|
||||
onClick={handleStart}
|
||||
isLoading={isStarting}
|
||||
disabled={selectedType === 'common_enemy' && !selectedChallengeId}
|
||||
className="w-full"
|
||||
icon={<Play className="w-4 h-4" />}
|
||||
>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Запустить {EVENT_INFO[selectedType].name}
|
||||
</Button>
|
||||
</NeonButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import type { Game } from '@/types'
|
||||
import { Gamepad2, Loader2 } from 'lucide-react'
|
||||
|
||||
interface SpinWheelProps {
|
||||
games: Game[]
|
||||
@@ -8,33 +9,80 @@ interface SpinWheelProps {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ITEM_HEIGHT = 100
|
||||
const VISIBLE_ITEMS = 5
|
||||
const SPIN_DURATION = 4000
|
||||
const EXTRA_ROTATIONS = 3
|
||||
const SPIN_DURATION = 5000 // ms
|
||||
const EXTRA_ROTATIONS = 5
|
||||
|
||||
// Цветовая палитра секторов
|
||||
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) {
|
||||
const [isSpinning, setIsSpinning] = useState(false)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const animationRef = useRef<number | null>(null)
|
||||
const [rotation, setRotation] = useState(0)
|
||||
|
||||
// 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 () => {
|
||||
if (isSpinning || disabled || games.length === 0) return
|
||||
|
||||
setIsSpinning(true)
|
||||
|
||||
// Get result from API first
|
||||
// Получаем результат от API
|
||||
const resultGame = await onSpin()
|
||||
if (!resultGame) {
|
||||
setIsSpinning(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Find target index
|
||||
// Находим индекс выигравшей игры
|
||||
const targetIndex = games.findIndex(g => g.id === resultGame.id)
|
||||
if (targetIndex === -1) {
|
||||
setIsSpinning(false)
|
||||
@@ -42,168 +90,245 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate animation
|
||||
const totalItems = games.length
|
||||
const fullRotations = EXTRA_ROTATIONS * totalItems
|
||||
const finalPosition = (fullRotations + targetIndex) * ITEM_HEIGHT
|
||||
// Рассчитываем угол для остановки
|
||||
// Указатель находится сверху (на 0°/360°)
|
||||
// Нам нужно чтобы нужный сектор оказался под указателем
|
||||
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 = () => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const progress = Math.min(elapsed / SPIN_DURATION, 1)
|
||||
setRotation(rotation + finalAngle)
|
||||
|
||||
// Easing function - starts fast, slows down at end
|
||||
const easeOut = 1 - Math.pow(1 - progress, 4)
|
||||
|
||||
const currentOffset = startOffset + (finalPosition - startOffset) * easeOut
|
||||
setOffset(currentOffset)
|
||||
|
||||
if (progress < 1) {
|
||||
animationRef.current = requestAnimationFrame(animate)
|
||||
} else {
|
||||
// Ждём окончания анимации
|
||||
setTimeout(() => {
|
||||
setIsSpinning(false)
|
||||
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 () => {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
return { game, color, path, textPos, maxTextLength }
|
||||
})
|
||||
}, [games, createSectorPath, getTextPosition])
|
||||
|
||||
if (games.length === 0) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
{/* Wheel container */}
|
||||
<div className="relative w-full max-w-md">
|
||||
{/* 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="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" />
|
||||
<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" />
|
||||
{/* Контейнер колеса */}
|
||||
<div className="relative">
|
||||
{/* Внешнее свечение */}
|
||||
<div className={`
|
||||
absolute -inset-4 rounded-full transition-all duration-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>
|
||||
|
||||
{/* Items container */}
|
||||
{/* Колесо */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative overflow-hidden"
|
||||
style={{ height: containerHeight }}
|
||||
className="relative"
|
||||
style={{ width: wheelSize, height: wheelSize }}
|
||||
>
|
||||
<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={{
|
||||
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) => {
|
||||
const realIndex = index % games.length
|
||||
const isSelected = !isSpinning && realIndex === currentIndex
|
||||
const opacity = getItemOpacity(index)
|
||||
<defs>
|
||||
{/* Тени для секторов */}
|
||||
<filter id="sectorShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="0" dy="0" stdDeviation="2" floodColor="#000" floodOpacity="0.3" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${game.id}-${index}`}
|
||||
className={`flex items-center gap-4 px-4 transition-transform duration-200 ${
|
||||
isSelected ? 'scale-105' : ''
|
||||
}`}
|
||||
style={{ height: ITEM_HEIGHT, opacity }}
|
||||
>
|
||||
{/* Game cover */}
|
||||
<div className="w-16 h-16 rounded-lg overflow-hidden bg-gray-700 flex-shrink-0">
|
||||
{game.cover_url ? (
|
||||
<img
|
||||
src={game.cover_url}
|
||||
alt={game.title}
|
||||
className="w-full h-full object-cover"
|
||||
{/* Секторы */}
|
||||
{sectors.map(({ game, color, path, textPos, maxTextLength }, index) => (
|
||||
<g key={game.id}>
|
||||
{/* Сектор */}
|
||||
<path
|
||||
d={path}
|
||||
fill={color.bg}
|
||||
stroke={color.border}
|
||||
strokeWidth="2"
|
||||
filter="url(#sectorShadow)"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-2xl">
|
||||
🎮
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Game info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-white truncate text-lg">
|
||||
{game.title}
|
||||
</h3>
|
||||
{game.genre && (
|
||||
<p className="text-sm text-gray-400 truncate">{game.genre}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Текст названия игры */}
|
||||
<text
|
||||
x={textPos.x}
|
||||
y={textPos.y}
|
||||
transform={`rotate(${textPos.rotation}, ${textPos.x}, ${textPos.y})`}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="white"
|
||||
fontSize={games.length > 10 ? "10" : games.length > 6 ? "11" : "13"}
|
||||
fontWeight="bold"
|
||||
style={{
|
||||
textShadow: '0 1px 3px rgba(0,0,0,0.8)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{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
|
||||
onClick={handleSpin}
|
||||
disabled={isSpinning || disabled}
|
||||
className={`
|
||||
relative px-12 py-4 text-xl font-bold rounded-full
|
||||
transition-all duration-300 transform
|
||||
${isSpinning || disabled
|
||||
? 'bg-gray-700 text-gray-400 cursor-not-allowed'
|
||||
: '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'
|
||||
absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
|
||||
w-24 h-24 rounded-full z-20
|
||||
flex flex-col items-center justify-center gap-1
|
||||
font-bold text-sm uppercase tracking-wider
|
||||
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 ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<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>
|
||||
<Loader2 className="w-8 h-8 animate-spin" />
|
||||
) : (
|
||||
'КРУТИТЬ!'
|
||||
<>
|
||||
<span className="text-xs">КРУТИТЬ</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -125,8 +125,8 @@ export function TelegramLink() {
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
isLinked
|
||||
? 'text-blue-400 hover:text-blue-300 hover:bg-gray-700'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-700'
|
||||
? 'text-blue-400 hover:text-blue-300 hover:bg-dark-700'
|
||||
: 'text-gray-400 hover:text-white hover:bg-dark-700'
|
||||
}`}
|
||||
title={isLinked ? 'Telegram привязан' : 'Привязать Telegram'}
|
||||
>
|
||||
@@ -134,17 +134,17 @@ export function TelegramLink() {
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 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="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="glass rounded-xl max-w-md w-full p-6 relative border border-dark-600">
|
||||
<button
|
||||
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" />
|
||||
</button>
|
||||
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -171,7 +171,7 @@ export function TelegramLink() {
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
{/* Avatar - Telegram avatar */}
|
||||
<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"
|
||||
/>
|
||||
) : (
|
||||
<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" />
|
||||
</div>
|
||||
)}
|
||||
{/* 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" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -205,7 +205,7 @@ export function TelegramLink() {
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
@@ -254,7 +254,7 @@ export function TelegramLink() {
|
||||
|
||||
<button
|
||||
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" />
|
||||
Открыть Telegram снова
|
||||
@@ -268,13 +268,13 @@ export function TelegramLink() {
|
||||
|
||||
<button
|
||||
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" />
|
||||
Открыть Telegram
|
||||
</button>
|
||||
|
||||
<p className="text-sm text-gray-500 text-center">
|
||||
<p className="text-sm text-gray-400 text-center">
|
||||
Ссылка действительна 10 минут
|
||||
</p>
|
||||
</>
|
||||
@@ -304,7 +304,7 @@ export function TelegramLink() {
|
||||
<button
|
||||
onClick={handleGenerateLink}
|
||||
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 ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
|
||||
@@ -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 { Gamepad2, LogOut, Trophy, User } from 'lucide-react'
|
||||
import { Gamepad2, LogOut, Trophy, User, Menu, X } from 'lucide-react'
|
||||
import { TelegramLink } from '@/components/TelegramLink'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
export function Layout() {
|
||||
const { user, isAuthenticated, logout } = useAuthStore()
|
||||
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 = () => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const isActiveLink = (path: string) => location.pathname === path
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* 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">
|
||||
<Link to="/" className="flex items-center gap-2 text-xl font-bold text-white">
|
||||
<Gamepad2 className="w-8 h-8 text-primary-500" />
|
||||
<span>Игровой Марафон</span>
|
||||
{/* Logo */}
|
||||
<Link
|
||||
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>
|
||||
|
||||
<nav className="flex items-center gap-4">
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center gap-6">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Link
|
||||
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" />
|
||||
<span>Марафоны</span>
|
||||
</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
|
||||
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" />
|
||||
<span>{user?.nickname}</span>
|
||||
@@ -46,7 +92,7 @@ export function Layout() {
|
||||
|
||||
<button
|
||||
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="Выйти"
|
||||
>
|
||||
<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 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>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Spacer for fixed header */}
|
||||
<div className="h-[72px]" />
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 container mx-auto px-4 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-800 border-t border-gray-700 py-4">
|
||||
<div className="container mx-auto px-4 text-center text-gray-500 text-sm">
|
||||
<footer className="bg-dark-800/50 border-t border-dark-600/50 py-6">
|
||||
<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">
|
||||
Игровой Марафон © {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>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -15,13 +15,13 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
ref={ref}
|
||||
disabled={disabled || isLoading}
|
||||
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',
|
||||
{
|
||||
'bg-primary-600 hover:bg-primary-700 text-white': variant === 'primary',
|
||||
'bg-gray-700 hover:bg-gray-600 text-white': variant === 'secondary',
|
||||
'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-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-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-4 py-2 text-base': size === 'md',
|
||||
'px-6 py-3 text-lg': size === 'lg',
|
||||
|
||||
@@ -4,11 +4,18 @@ import { clsx } from 'clsx'
|
||||
interface CardProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
hover?: boolean
|
||||
}
|
||||
|
||||
export function Card({ children, className }: CardProps) {
|
||||
export function Card({ children, className, hover = false }: CardProps) {
|
||||
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}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect } from 'react'
|
||||
import { AlertTriangle, Info, Trash2, X } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
import { useConfirmStore, type ConfirmVariant } from '@/store/confirm'
|
||||
import { Button } from './Button'
|
||||
import { NeonButton } from './NeonButton'
|
||||
|
||||
const icons: Record<ConfirmVariant, React.ReactNode> = {
|
||||
danger: <Trash2 className="w-6 h-6" />,
|
||||
@@ -11,15 +11,15 @@ const icons: Record<ConfirmVariant, React.ReactNode> = {
|
||||
}
|
||||
|
||||
const iconStyles: Record<ConfirmVariant, string> = {
|
||||
danger: 'bg-red-500/20 text-red-500',
|
||||
warning: 'bg-yellow-500/20 text-yellow-500',
|
||||
info: 'bg-blue-500/20 text-blue-500',
|
||||
danger: 'bg-red-500/10 text-red-400 border border-red-500/30',
|
||||
warning: 'bg-yellow-500/10 text-yellow-400 border border-yellow-500/30',
|
||||
info: 'bg-neon-500/10 text-neon-400 border border-neon-500/30',
|
||||
}
|
||||
|
||||
const buttonVariants: Record<ConfirmVariant, 'danger' | 'primary' | 'secondary'> = {
|
||||
danger: 'danger',
|
||||
warning: 'primary',
|
||||
info: 'primary',
|
||||
const confirmButtonStyles: Record<ConfirmVariant, string> = {
|
||||
danger: 'border-red-500/50 text-red-400 hover:bg-red-500/10 hover:border-red-500',
|
||||
warning: 'border-yellow-500/50 text-yellow-400 hover:bg-yellow-500/10 hover:border-yellow-500',
|
||||
info: '', // Will use NeonButton default
|
||||
}
|
||||
|
||||
export function ConfirmModal() {
|
||||
@@ -62,7 +62,7 @@ export function ConfirmModal() {
|
||||
/>
|
||||
|
||||
{/* 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 */}
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
@@ -89,20 +89,31 @@ export function ConfirmModal() {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
<NeonButton
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
{options.cancelText || 'Отмена'}
|
||||
</Button>
|
||||
<Button
|
||||
variant={buttonVariants[variant]}
|
||||
</NeonButton>
|
||||
{variant === 'info' ? (
|
||||
<NeonButton
|
||||
className="flex-1"
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{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>
|
||||
|
||||
215
frontend/src/components/ui/GlassCard.tsx
Normal file
215
frontend/src/components/ui/GlassCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
frontend/src/components/ui/GlitchText.tsx
Normal file
116
frontend/src/components/ui/GlitchText.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
return (
|
||||
<div className="w-full">
|
||||
{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>
|
||||
)}
|
||||
@@ -19,15 +19,16 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
ref={ref}
|
||||
id={id}
|
||||
className={clsx(
|
||||
'w-full px-4 py-2 bg-gray-800 border rounded-lg text-white placeholder-gray-500',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
|
||||
'transition-colors',
|
||||
error ? 'border-red-500' : 'border-gray-700',
|
||||
'w-full px-4 py-3 bg-dark-800 border rounded-lg text-white placeholder-gray-500',
|
||||
'focus:outline-none focus:border-neon-500',
|
||||
'focus:shadow-[0_0_0_3px_rgba(34,211,238,0.1),0_0_8px_rgba(34,211,238,0.15)]',
|
||||
'transition-all duration-200',
|
||||
error ? 'border-red-500' : 'border-dark-600',
|
||||
className
|
||||
)}
|
||||
{...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>
|
||||
)
|
||||
}
|
||||
|
||||
174
frontend/src/components/ui/NeonButton.tsx
Normal file
174
frontend/src/components/ui/NeonButton.tsx
Normal 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'
|
||||
@@ -3,6 +3,8 @@ import { usersApi } from '@/api'
|
||||
|
||||
// Глобальный кэш для blob URL аватарок
|
||||
const avatarCache = new Map<number, string>()
|
||||
// Пользователи, для которых нужно сбросить HTTP-кэш при следующем запросе
|
||||
const needsCacheBust = new Set<number>()
|
||||
|
||||
interface UserAvatarProps {
|
||||
userId: number
|
||||
@@ -10,6 +12,7 @@ interface UserAvatarProps {
|
||||
nickname: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
className?: string
|
||||
version?: number // Для принудительного обновления при смене аватара
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
@@ -18,7 +21,7 @@ const sizeClasses = {
|
||||
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 [failed, setFailed] = useState(false)
|
||||
|
||||
@@ -28,16 +31,31 @@ export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className
|
||||
return
|
||||
}
|
||||
|
||||
// Проверяем кэш
|
||||
// Если version > 0, значит аватар обновился - сбрасываем кэш
|
||||
const shouldBustCache = version > 0 || needsCacheBust.has(userId)
|
||||
|
||||
// Проверяем кэш только если не нужен bust
|
||||
if (!shouldBustCache) {
|
||||
const cached = avatarCache.get(userId)
|
||||
if (cached) {
|
||||
setBlobUrl(cached)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Очищаем старый кэш если bust
|
||||
if (shouldBustCache) {
|
||||
const cached = avatarCache.get(userId)
|
||||
if (cached) {
|
||||
URL.revokeObjectURL(cached)
|
||||
avatarCache.delete(userId)
|
||||
}
|
||||
needsCacheBust.delete(userId)
|
||||
}
|
||||
|
||||
// Загружаем аватарку
|
||||
let cancelled = false
|
||||
usersApi.getAvatarUrl(userId)
|
||||
usersApi.getAvatarUrl(userId, shouldBustCache)
|
||||
.then(url => {
|
||||
if (!cancelled) {
|
||||
avatarCache.set(userId, url)
|
||||
@@ -53,7 +71,7 @@ export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [userId, hasAvatar])
|
||||
}, [userId, hasAvatar, version])
|
||||
|
||||
const sizeClass = sizeClasses[size]
|
||||
|
||||
@@ -84,4 +102,6 @@ export function clearAvatarCache(userId: number) {
|
||||
URL.revokeObjectURL(cached)
|
||||
avatarCache.delete(userId)
|
||||
}
|
||||
// Помечаем, что при следующем запросе нужно сбросить HTTP-кэш браузера
|
||||
needsCacheBust.add(userId)
|
||||
}
|
||||
|
||||
@@ -4,3 +4,8 @@ export { Card, CardHeader, CardTitle, CardContent } from './Card'
|
||||
export { ToastContainer } from './Toast'
|
||||
export { ConfirmModal } from './ConfirmModal'
|
||||
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'
|
||||
|
||||
@@ -2,11 +2,129 @@
|
||||
@tailwind components;
|
||||
@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 {
|
||||
width: 6px;
|
||||
}
|
||||
@@ -16,46 +134,450 @@ body {
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
background: var(--color-dark-500);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280;
|
||||
background: var(--color-neon-500);
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #4b5563 transparent;
|
||||
/* ========================================
|
||||
Glitch Effect
|
||||
======================================== */
|
||||
.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 {
|
||||
/* Buttons */
|
||||
.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 {
|
||||
@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 {
|
||||
@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 {
|
||||
@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 {
|
||||
@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 {
|
||||
@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 {
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { assignmentsApi } from '@/api'
|
||||
import type { AssignmentDetail } from '@/types'
|
||||
import { Card, CardContent, Button } from '@/components/ui'
|
||||
import { GlassCard, NeonButton } from '@/components/ui'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useToast } from '@/store/toast'
|
||||
import {
|
||||
ArrowLeft, Loader2, ExternalLink, Image, MessageSquare,
|
||||
ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle,
|
||||
Send, Flag
|
||||
Send, Flag, Gamepad2, Zap, Trophy
|
||||
} from 'lucide-react'
|
||||
|
||||
export function AssignmentDetailPage() {
|
||||
@@ -142,137 +142,167 @@ export function AssignmentDetailPage() {
|
||||
return `${hours}ч ${minutes}м`
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return (
|
||||
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full text-sm flex items-center gap-1">
|
||||
<CheckCircle className="w-4 h-4" /> Выполнено
|
||||
</span>
|
||||
)
|
||||
return {
|
||||
color: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
icon: <CheckCircle className="w-4 h-4" />,
|
||||
text: 'Выполнено',
|
||||
}
|
||||
case 'dropped':
|
||||
return (
|
||||
<span className="px-3 py-1 bg-red-500/20 text-red-400 rounded-full text-sm flex items-center gap-1">
|
||||
<XCircle className="w-4 h-4" /> Пропущено
|
||||
</span>
|
||||
)
|
||||
return {
|
||||
color: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
icon: <XCircle className="w-4 h-4" />,
|
||||
text: 'Пропущено',
|
||||
}
|
||||
case 'returned':
|
||||
return (
|
||||
<span className="px-3 py-1 bg-orange-500/20 text-orange-400 rounded-full text-sm flex items-center gap-1">
|
||||
<AlertTriangle className="w-4 h-4" /> Возвращено
|
||||
</span>
|
||||
)
|
||||
return {
|
||||
color: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
|
||||
icon: <AlertTriangle className="w-4 h-4" />,
|
||||
text: 'Возвращено',
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<span className="px-3 py-1 bg-blue-500/20 text-blue-400 rounded-full text-sm">
|
||||
Активно
|
||||
</span>
|
||||
)
|
||||
return {
|
||||
color: 'bg-neon-500/20 text-neon-400 border-neon-500/30',
|
||||
icon: <Zap className="w-4 h-4" />,
|
||||
text: 'Активно',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
|
||||
<p className="text-gray-400">Загрузка...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !assignment) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto text-center py-12">
|
||||
<p className="text-red-400 mb-4">{error || 'Задание не найдено'}</p>
|
||||
<Button onClick={() => navigate(-1)}>Назад</Button>
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<GlassCard className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-red-500/10 border border-red-500/30 flex items-center justify-center">
|
||||
<AlertTriangle className="w-8 h-8 text-red-400" />
|
||||
</div>
|
||||
<p className="text-gray-400 mb-6">{error || 'Задание не найдено'}</p>
|
||||
<NeonButton variant="outline" onClick={() => navigate(-1)}>
|
||||
Назад
|
||||
</NeonButton>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const dispute = assignment.dispute
|
||||
const status = getStatusConfig(assignment.status)
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<button onClick={() => navigate(-1)} className="text-gray-400 hover:text-white">
|
||||
<ArrowLeft className="w-6 h-6" />
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="w-10 h-10 rounded-xl bg-dark-700 border border-dark-600 flex items-center justify-center text-gray-400 hover:text-white hover:border-neon-500/30 transition-all"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">Детали выполнения</h1>
|
||||
<p className="text-sm text-gray-400">Просмотр доказательства</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Challenge info */}
|
||||
<Card className="mb-6">
|
||||
<CardContent>
|
||||
<GlassCard variant="neon">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 border border-neon-500/20 flex items-center justify-center">
|
||||
<Gamepad2 className="w-7 h-7 text-neon-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">{assignment.challenge.game.title}</p>
|
||||
<h2 className="text-xl font-bold text-white">{assignment.challenge.title}</h2>
|
||||
</div>
|
||||
{getStatusBadge(assignment.status)}
|
||||
</div>
|
||||
<span className={`px-3 py-1.5 rounded-full text-sm font-medium border flex items-center gap-1.5 ${status.color}`}>
|
||||
{status.icon}
|
||||
{status.text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-300 mb-4">{assignment.challenge.description}</p>
|
||||
|
||||
<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} очков
|
||||
</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}
|
||||
</span>
|
||||
{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} мин
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
<strong>Выполнил:</strong> {assignment.participant.nickname}
|
||||
<span className="text-gray-500">Выполнил:</span>{' '}
|
||||
<span className="text-white">{assignment.participant.nickname}</span>
|
||||
</p>
|
||||
{assignment.completed_at && (
|
||||
<p>
|
||||
<strong>Дата:</strong> {formatDate(assignment.completed_at)}
|
||||
<span className="text-gray-500">Дата:</span>{' '}
|
||||
<span className="text-white">{formatDate(assignment.completed_at)}</span>
|
||||
</p>
|
||||
)}
|
||||
{assignment.points_earned > 0 && (
|
||||
<p>
|
||||
<strong>Получено очков:</strong> {assignment.points_earned}
|
||||
<span className="text-gray-500">Получено очков:</span>{' '}
|
||||
<span className="text-neon-400 font-semibold">{assignment.points_earned}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
|
||||
{/* Proof section */}
|
||||
<Card className="mb-6">
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||
<Image className="w-5 h-5" />
|
||||
Доказательство
|
||||
</h3>
|
||||
<GlassCard>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
|
||||
<Image className="w-5 h-5 text-accent-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">Доказательство</h3>
|
||||
<p className="text-sm text-gray-400">Пруф выполнения задания</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Proof media (image or video) */}
|
||||
{assignment.proof_image_url && (
|
||||
<div className="mb-4">
|
||||
<div className="mb-4 rounded-xl overflow-hidden border border-dark-600">
|
||||
{proofMediaBlobUrl ? (
|
||||
proofMediaType === 'video' ? (
|
||||
<video
|
||||
src={proofMediaBlobUrl}
|
||||
controls
|
||||
className="w-full rounded-lg max-h-96 bg-gray-900"
|
||||
className="w-full max-h-96 bg-dark-900"
|
||||
preload="metadata"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={proofMediaBlobUrl}
|
||||
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" />
|
||||
</div>
|
||||
)}
|
||||
@@ -286,7 +316,7 @@ export function AssignmentDetailPage() {
|
||||
href={assignment.proof_url}
|
||||
target="_blank"
|
||||
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" />
|
||||
{assignment.proof_url}
|
||||
@@ -296,42 +326,45 @@ export function AssignmentDetailPage() {
|
||||
|
||||
{/* 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-white">{assignment.proof_comment}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
|
||||
{/* Dispute button */}
|
||||
{assignment.can_dispute && !dispute && !showDisputeForm && (
|
||||
<Button
|
||||
variant="danger"
|
||||
className="w-full mb-6"
|
||||
<button
|
||||
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"
|
||||
onClick={() => setShowDisputeForm(true)}
|
||||
>
|
||||
<Flag className="w-4 h-4 mr-2" />
|
||||
<Flag className="w-4 h-4" />
|
||||
Оспорить выполнение
|
||||
</Button>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Dispute creation form */}
|
||||
{showDisputeForm && !dispute && (
|
||||
<Card className="mb-6 border-red-500/50">
|
||||
<CardContent>
|
||||
<h3 className="text-lg font-bold text-red-400 mb-4 flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Оспорить выполнение
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-400 text-sm mb-4">
|
||||
Опишите причину оспаривания. После создания у участников будет 24 часа для голосования.
|
||||
</p>
|
||||
<GlassCard className="border-red-500/30">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-red-500/20 flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-red-400">Оспорить выполнение</h3>
|
||||
<p className="text-sm text-gray-400">У участников будет 24 часа для голосования</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="input w-full min-h-[100px] resize-none mb-4"
|
||||
@@ -341,115 +374,120 @@ export function AssignmentDetailPage() {
|
||||
/>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="danger"
|
||||
className="flex-1"
|
||||
<NeonButton
|
||||
className="flex-1 border-red-500/50 bg-red-500/10 hover:bg-red-500/20 text-red-400"
|
||||
onClick={handleCreateDispute}
|
||||
isLoading={isCreatingDispute}
|
||||
disabled={disputeReason.trim().length < 10}
|
||||
>
|
||||
Оспорить
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowDisputeForm(false)
|
||||
setDisputeReason('')
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
</NeonButton>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Dispute section */}
|
||||
{dispute && (
|
||||
<Card className={`mb-6 ${dispute.status === 'open' ? 'border-yellow-500/50' : ''}`}>
|
||||
<CardContent>
|
||||
<GlassCard className={dispute.status === 'open' ? 'border-yellow-500/30' : ''}>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h3 className="text-lg font-bold text-yellow-400 flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Оспаривание
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-400" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-yellow-400">Оспаривание</h3>
|
||||
</div>
|
||||
|
||||
{dispute.status === 'open' ? (
|
||||
<span className="px-3 py-1 bg-yellow-500/20 text-yellow-400 rounded-full text-sm flex items-center gap-1">
|
||||
<span className="px-3 py-1.5 bg-yellow-500/20 text-yellow-400 rounded-lg text-sm font-medium border border-yellow-500/30 flex items-center gap-1.5">
|
||||
<Clock className="w-4 h-4" />
|
||||
{getTimeRemaining(dispute.expires_at)}
|
||||
</span>
|
||||
) : dispute.status === 'valid' ? (
|
||||
<span className="px-3 py-1 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" />
|
||||
Пруф валиден
|
||||
</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" />
|
||||
Пруф невалиден
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
<div className="mb-4 text-sm text-gray-400">
|
||||
<p>
|
||||
Открыл: <span className="text-white">{dispute.raised_by.nickname}</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
<p>
|
||||
Дата: <span className="text-white">{formatDate(dispute.created_at)}</span>
|
||||
</p>
|
||||
</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-white">{dispute.reason}</p>
|
||||
</div>
|
||||
|
||||
{/* Voting section */}
|
||||
{dispute.status === 'open' && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-medium text-gray-300 mb-3">Голосование</h4>
|
||||
<div className="mb-6 p-4 bg-dark-700/30 rounded-xl border border-dark-600">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Голосование</h4>
|
||||
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<div className="flex items-center gap-6 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ThumbsUp className="w-5 h-5 text-green-500" />
|
||||
<span className="text-green-400 font-medium">{dispute.votes_valid}</span>
|
||||
<div className="w-8 h-8 rounded-lg bg-green-500/20 flex items-center justify-center">
|
||||
<ThumbsUp className="w-4 h-4 text-green-400" />
|
||||
</div>
|
||||
<span className="text-green-400 font-bold text-lg">{dispute.votes_valid}</span>
|
||||
<span className="text-gray-500 text-sm">валидно</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ThumbsDown className="w-5 h-5 text-red-500" />
|
||||
<span className="text-red-400 font-medium">{dispute.votes_invalid}</span>
|
||||
<div className="w-8 h-8 rounded-lg bg-red-500/20 flex items-center justify-center">
|
||||
<ThumbsDown className="w-4 h-4 text-red-400" />
|
||||
</div>
|
||||
<span className="text-red-400 font-bold text-lg">{dispute.votes_invalid}</span>
|
||||
<span className="text-gray-500 text-sm">невалидно</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant={dispute.my_vote === true ? 'primary' : 'secondary'}
|
||||
className="flex-1"
|
||||
<NeonButton
|
||||
className={`flex-1 ${dispute.my_vote === true ? 'bg-green-500/20 border-green-500/50 text-green-400' : ''}`}
|
||||
variant="outline"
|
||||
onClick={() => handleVote(true)}
|
||||
isLoading={isVoting}
|
||||
disabled={isVoting}
|
||||
icon={<ThumbsUp className="w-4 h-4" />}
|
||||
>
|
||||
<ThumbsUp className="w-4 h-4 mr-2" />
|
||||
Валидно
|
||||
</Button>
|
||||
<Button
|
||||
variant={dispute.my_vote === false ? 'danger' : 'secondary'}
|
||||
className="flex-1"
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
className={`flex-1 ${dispute.my_vote === false ? 'bg-red-500/20 border-red-500/50 text-red-400' : ''}`}
|
||||
variant="outline"
|
||||
onClick={() => handleVote(false)}
|
||||
isLoading={isVoting}
|
||||
disabled={isVoting}
|
||||
icon={<ThumbsDown className="w-4 h-4" />}
|
||||
>
|
||||
<ThumbsDown className="w-4 h-4 mr-2" />
|
||||
Невалидно
|
||||
</Button>
|
||||
</NeonButton>
|
||||
</div>
|
||||
|
||||
{dispute.my_vote !== null && (
|
||||
<p className="text-sm text-gray-500 mt-2 text-center">
|
||||
Вы проголосовали: {dispute.my_vote ? 'валидно' : 'невалидно'}
|
||||
<p className="text-sm text-gray-500 mt-3 text-center">
|
||||
Вы проголосовали: <span className={dispute.my_vote ? 'text-green-400' : 'text-red-400'}>
|
||||
{dispute.my_vote ? 'валидно' : 'невалидно'}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -457,17 +495,19 @@ export function AssignmentDetailPage() {
|
||||
|
||||
{/* Comments section */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-300 mb-3 flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<MessageSquare className="w-4 h-4 text-gray-400" />
|
||||
<h4 className="text-sm font-semibold text-white">
|
||||
Обсуждение ({dispute.comments.length})
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{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) => (
|
||||
<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">
|
||||
<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.id === user?.id && ' (Вы)'}
|
||||
</span>
|
||||
@@ -497,18 +537,16 @@ export function AssignmentDetailPage() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
<NeonButton
|
||||
onClick={handleAddComment}
|
||||
isLoading={isAddingComment}
|
||||
disabled={!commentText.trim()}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
icon={<Send className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { marathonsApi } from '@/api'
|
||||
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui'
|
||||
import { Globe, Lock, Users, UserCog, ArrowLeft } from 'lucide-react'
|
||||
import { NeonButton, Input, GlassCard } from '@/components/ui'
|
||||
import { Globe, Lock, Users, UserCog, ArrowLeft, Gamepad2, AlertCircle, Sparkles, Calendar, Clock } from 'lucide-react'
|
||||
import type { GameProposalMode } from '@/types'
|
||||
|
||||
const createSchema = z.object({
|
||||
@@ -64,25 +64,38 @@ export function CreateMarathonPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto">
|
||||
<div className="max-w-xl mx-auto">
|
||||
{/* Back button */}
|
||||
<Link to="/marathons" className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<Link
|
||||
to="/marathons"
|
||||
className="inline-flex items-center gap-2 text-gray-400 hover:text-neon-400 mb-6 transition-colors group"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
|
||||
К списку марафонов
|
||||
</Link>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Создать марафон</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<GlassCard variant="neon">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center border border-neon-500/30">
|
||||
<Gamepad2 className="w-7 h-7 text-neon-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Создать марафон</h1>
|
||||
<p className="text-gray-400 text-sm">Настройте свой игровой марафон</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-500/20 border border-red-500 rounded-lg text-red-500 text-sm">
|
||||
{error}
|
||||
<div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Basic info */}
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Название"
|
||||
placeholder="Введите название марафона"
|
||||
@@ -91,132 +104,209 @@ export function CreateMarathonPage() {
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Описание (необязательно)
|
||||
</label>
|
||||
<textarea
|
||||
className="input min-h-[100px] resize-none"
|
||||
placeholder="Введите описание"
|
||||
placeholder="Расскажите о вашем марафоне..."
|
||||
{...register('description')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Дата начала"
|
||||
{/* Date and duration */}
|
||||
<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"
|
||||
error={errors.start_date?.message}
|
||||
className="input w-full"
|
||||
{...register('start_date')}
|
||||
/>
|
||||
{errors.start_date && (
|
||||
<p className="text-red-400 text-xs mt-1">{errors.start_date.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Длительность (дней)"
|
||||
<div>
|
||||
<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"
|
||||
error={errors.duration_days?.message}
|
||||
className="input w-full"
|
||||
min={1}
|
||||
max={365}
|
||||
{...register('duration_days', { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.duration_days && (
|
||||
<p className="text-red-400 text-xs mt-1">{errors.duration_days.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Тип марафона */}
|
||||
{/* Marathon type */}
|
||||
<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>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue('is_public', false)}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
!isPublic
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
|
||||
}`}
|
||||
className={`
|
||||
relative p-4 rounded-xl border-2 transition-all duration-300 text-left group
|
||||
${!isPublic
|
||||
? 'border-neon-500/50 bg-neon-500/10 shadow-[0_0_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={`text-sm font-medium ${!isPublic ? 'text-white' : 'text-gray-300'}`}>
|
||||
<div className={`
|
||||
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
|
||||
${!isPublic ? 'bg-neon-500/20' : 'bg-dark-600'}
|
||||
`}>
|
||||
<Lock className={`w-5 h-5 ${!isPublic ? 'text-neon-400' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
<div className={`font-semibold mb-1 ${!isPublic ? 'text-white' : 'text-gray-300'}`}>
|
||||
Закрытый
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Вход по коду
|
||||
<div className="text-xs text-gray-500">
|
||||
Вход только по коду приглашения
|
||||
</div>
|
||||
{!isPublic && (
|
||||
<div className="absolute top-3 right-3">
|
||||
<Sparkles className="w-4 h-4 text-neon-400" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue('is_public', true)}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
isPublic
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
|
||||
}`}
|
||||
className={`
|
||||
relative p-4 rounded-xl border-2 transition-all duration-300 text-left group
|
||||
${isPublic
|
||||
? 'border-accent-500/50 bg-accent-500/10 shadow-[0_0_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={`text-sm font-medium ${isPublic ? 'text-white' : 'text-gray-300'}`}>
|
||||
<div className={`
|
||||
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
|
||||
${isPublic ? 'bg-accent-500/20' : 'bg-dark-600'}
|
||||
`}>
|
||||
<Globe className={`w-5 h-5 ${isPublic ? 'text-accent-400' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
<div className={`font-semibold mb-1 ${isPublic ? 'text-white' : 'text-gray-300'}`}>
|
||||
Открытый
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Виден всем
|
||||
<div className="text-xs text-gray-500">
|
||||
Виден всем пользователям
|
||||
</div>
|
||||
{isPublic && (
|
||||
<div className="absolute top-3 right-3">
|
||||
<Sparkles className="w-4 h-4 text-accent-400" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кто может предлагать игры */}
|
||||
{/* Game proposal mode */}
|
||||
<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>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue('game_proposal_mode', 'all_participants')}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
gameProposalMode === 'all_participants'
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
|
||||
}`}
|
||||
className={`
|
||||
relative p-4 rounded-xl border-2 transition-all duration-300 text-left
|
||||
${gameProposalMode === 'all_participants'
|
||||
? 'border-neon-500/50 bg-neon-500/10 shadow-[0_0_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={`text-sm font-medium ${gameProposalMode === 'all_participants' ? 'text-white' : 'text-gray-300'}`}>
|
||||
<div className={`
|
||||
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
|
||||
${gameProposalMode === 'all_participants' ? 'bg-neon-500/20' : 'bg-dark-600'}
|
||||
`}>
|
||||
<Users className={`w-5 h-5 ${gameProposalMode === 'all_participants' ? 'text-neon-400' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
<div className={`font-semibold mb-1 ${gameProposalMode === 'all_participants' ? 'text-white' : 'text-gray-300'}`}>
|
||||
Все участники
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
С модерацией
|
||||
<div className="text-xs text-gray-500">
|
||||
С модерацией организатором
|
||||
</div>
|
||||
{gameProposalMode === 'all_participants' && (
|
||||
<div className="absolute top-3 right-3">
|
||||
<Sparkles className="w-4 h-4 text-neon-400" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue('game_proposal_mode', 'organizer_only')}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
gameProposalMode === 'organizer_only'
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
|
||||
}`}
|
||||
className={`
|
||||
relative p-4 rounded-xl border-2 transition-all duration-300 text-left
|
||||
${gameProposalMode === 'organizer_only'
|
||||
? 'border-accent-500/50 bg-accent-500/10 shadow-[0_0_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={`text-sm font-medium ${gameProposalMode === 'organizer_only' ? 'text-white' : 'text-gray-300'}`}>
|
||||
<div className={`
|
||||
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
|
||||
${gameProposalMode === 'organizer_only' ? 'bg-accent-500/20' : 'bg-dark-600'}
|
||||
`}>
|
||||
<UserCog className={`w-5 h-5 ${gameProposalMode === 'organizer_only' ? 'text-accent-400' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
<div className={`font-semibold mb-1 ${gameProposalMode === 'organizer_only' ? 'text-white' : 'text-gray-300'}`}>
|
||||
Только организатор
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
<div className="text-xs text-gray-500">
|
||||
Без модерации
|
||||
</div>
|
||||
{gameProposalMode === 'organizer_only' && (
|
||||
<div className="absolute top-3 right-3">
|
||||
<Sparkles className="w-4 h-4 text-accent-400" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-4 border-t border-dark-600">
|
||||
<NeonButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => navigate('/marathons')}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button type="submit" className="flex-1" isLoading={isLoading}>
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
isLoading={isLoading}
|
||||
icon={<Sparkles className="w-4 h-4" />}
|
||||
>
|
||||
Создать
|
||||
</Button>
|
||||
</NeonButton>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,113 +1,251 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Gamepad2, Users, Trophy, Sparkles } from 'lucide-react'
|
||||
import { NeonButton, GradientButton, FeatureCard } from '@/components/ui'
|
||||
import { Gamepad2, Users, Trophy, Sparkles, Zap, Target, Crown, ArrowRight } from 'lucide-react'
|
||||
|
||||
export function HomePage() {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
{/* Hero */}
|
||||
<div className="py-12">
|
||||
<div className="flex justify-center mb-6">
|
||||
<Gamepad2 className="w-20 h-20 text-primary-500" />
|
||||
<div className="-mt-8 relative">
|
||||
{/* Global animated background - covers entire page */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
{/* Gradient orbs */}
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div className="flex gap-4 justify-center">
|
||||
{/* CTA Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
{isAuthenticated ? (
|
||||
<Link to="/marathons">
|
||||
<Button size="lg">К марафонам</Button>
|
||||
<GradientButton size="lg" icon={<ArrowRight className="w-5 h-5" />}>
|
||||
К марафонам
|
||||
</GradientButton>
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/register">
|
||||
<Button size="lg">Начать</Button>
|
||||
<GradientButton size="lg" icon={<Zap className="w-5 h-5" />}>
|
||||
Начать играть
|
||||
</GradientButton>
|
||||
</Link>
|
||||
<Link to="/login">
|
||||
<Button size="lg" variant="secondary">Войти</Button>
|
||||
<NeonButton size="lg" variant="outline" color="neon">
|
||||
Войти
|
||||
</NeonButton>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="grid md:grid-cols-3 gap-8 py-12">
|
||||
<div className="card text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<Sparkles className="w-12 h-12 text-yellow-500" />
|
||||
{/* Scroll indicator */}
|
||||
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce">
|
||||
<div className="w-6 h-10 border-2 border-gray-600 rounded-full flex justify-center pt-2">
|
||||
<div className="w-1 h-2 bg-neon-500 rounded-full animate-pulse" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white mb-2">Случайные челленджи</h3>
|
||||
<p className="text-gray-400">
|
||||
Крутите колесо, чтобы получить случайную игру и задание. Проверьте свои навыки неожиданным способом!
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div className="card text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<Users className="w-12 h-12 text-green-500" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white mb-2">Играйте с друзьями</h3>
|
||||
<p className="text-gray-400">
|
||||
Создавайте приватные марафоны и приглашайте друзей. Каждый добавляет свои любимые игры.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card text-center">
|
||||
<div className="flex justify-center mb-4">
|
||||
<Trophy className="w-12 h-12 text-primary-500" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white mb-2">Соревнуйтесь за очки</h3>
|
||||
<p className="text-gray-400">
|
||||
Выполняйте задания, чтобы зарабатывать очки. Собирайте серии для бонусных множителей!
|
||||
</p>
|
||||
<div className="grid md:grid-cols-3 gap-6 stagger-children">
|
||||
<FeatureCard
|
||||
icon={<Sparkles className="w-7 h-7" />}
|
||||
title="Случайные челленджи"
|
||||
description="Крутите колесо и получайте уникальные задания. ИИ генерирует челленджи специально под ваши игры."
|
||||
color="neon"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<Users className="w-7 h-7" />}
|
||||
title="Играйте с друзьями"
|
||||
description="Создавайте приватные марафоны. Каждый добавляет свои игры, все соревнуются на равных."
|
||||
color="purple"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<Trophy className="w-7 h-7" />}
|
||||
title="Зарабатывайте очки"
|
||||
description="Выполняйте задания, собирайте серии побед. Бонусные множители за стрики!"
|
||||
color="pink"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it works */}
|
||||
<div className="py-12">
|
||||
<h2 className="text-2xl font-bold text-white mb-8">Как это работает</h2>
|
||||
<div className="grid md:grid-cols-4 gap-6 text-left">
|
||||
<section className="py-24 relative">
|
||||
<div className="max-w-6xl mx-auto px-4 relative z-10">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||
Как это работает
|
||||
</h2>
|
||||
<p className="text-gray-400">
|
||||
Четыре простых шага до победы
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="relative">
|
||||
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">1</div>
|
||||
<div className="relative z-10 pt-6">
|
||||
<h4 className="font-bold text-white mb-2">Создайте марафон</h4>
|
||||
<p className="text-gray-400 text-sm">Начните новый марафон и пригласите друзей по уникальному коду</p>
|
||||
{/* Connection line */}
|
||||
<div className="hidden md:block absolute top-12 left-0 right-0 h-0.5 bg-gradient-to-r from-neon-500 via-accent-500 to-pink-500" />
|
||||
|
||||
<div className="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 className="relative">
|
||||
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">2</div>
|
||||
<div className="relative z-10 pt-6">
|
||||
<h4 className="font-bold text-white mb-2">Добавьте игры</h4>
|
||||
<p className="text-gray-400 text-sm">Все добавляют игры, в которые хотят играть. ИИ генерирует задания</p>
|
||||
<h4 className="text-lg font-semibold text-white mb-2">
|
||||
{item.title}
|
||||
</h4>
|
||||
<p className="text-gray-400 text-sm">
|
||||
{item.desc}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="relative">
|
||||
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">3</div>
|
||||
<div className="relative z-10 pt-6">
|
||||
<h4 className="font-bold text-white mb-2">Крутите и играйте</h4>
|
||||
<p className="text-gray-400 text-sm">Крутите колесо, получите задание, выполните его и отправьте доказательство</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* CTA Section */}
|
||||
<section className="py-24 relative">
|
||||
<div className="max-w-4xl mx-auto px-4 text-center">
|
||||
<div className="glass-neon rounded-2xl p-12 relative overflow-hidden">
|
||||
{/* Background glow */}
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-neon-500/5 via-accent-500/5 to-pink-500/5" />
|
||||
|
||||
<div className="relative">
|
||||
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">4</div>
|
||||
<div className="relative z-10 pt-6">
|
||||
<h4 className="font-bold text-white mb-2">Победите!</h4>
|
||||
<p className="text-gray-400 text-sm">Зарабатывайте очки, поднимайтесь в таблице лидеров, станьте чемпионом!</p>
|
||||
</div>
|
||||
<div className="relative z-10">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||
Готовы к соревнованиям?
|
||||
</h2>
|
||||
<p className="text-gray-300 mb-8 max-w-xl mx-auto">
|
||||
Создавайте марафоны, приглашайте друзей и соревнуйтесь в игровых челленджах
|
||||
</p>
|
||||
|
||||
{isAuthenticated ? (
|
||||
<Link to="/marathons">
|
||||
<GradientButton size="lg" icon={<ArrowRight className="w-5 h-5" />}>
|
||||
Перейти к марафонам
|
||||
</GradientButton>
|
||||
</Link>
|
||||
) : (
|
||||
<Link to="/register">
|
||||
<GradientButton size="lg" icon={<Zap className="w-5 h-5" />}>
|
||||
Создать аккаунт бесплатно
|
||||
</GradientButton>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { marathonsApi } from '@/api'
|
||||
import type { MarathonPublicInfo } from '@/types'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { Users, Loader2, Trophy, UserPlus, LogIn } from 'lucide-react'
|
||||
import { NeonButton, GlassCard } from '@/components/ui'
|
||||
import { Users, Loader2, Trophy, UserPlus, LogIn, Gamepad2, AlertCircle, Sparkles, Crown } from 'lucide-react'
|
||||
|
||||
export function InvitePage() {
|
||||
const { code } = useParams<{ code: string }>()
|
||||
@@ -63,8 +63,9 @@ export function InvitePage() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
|
||||
<p className="text-gray-400">Загрузка приглашения...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -72,97 +73,154 @@ export function InvitePage() {
|
||||
if (error || !marathon) {
|
||||
return (
|
||||
<div className="max-w-md mx-auto">
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
<div className="text-red-400 mb-4">{error || 'Марафон не найден'}</div>
|
||||
<GlassCard className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-red-500/10 border border-red-500/30 flex items-center justify-center">
|
||||
<AlertCircle className="w-8 h-8 text-red-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-white mb-2">Ошибка</h2>
|
||||
<p className="text-gray-400 mb-6">{error || 'Марафон не найден'}</p>
|
||||
<Link to="/marathons">
|
||||
<Button variant="secondary">К списку марафонов</Button>
|
||||
<NeonButton variant="outline">К списку марафонов</NeonButton>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const statusText = {
|
||||
preparing: 'Подготовка',
|
||||
active: 'Активен',
|
||||
finished: 'Завершён',
|
||||
}[marathon.status]
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case 'preparing':
|
||||
return {
|
||||
color: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||
text: 'Подготовка',
|
||||
dot: 'bg-yellow-500',
|
||||
}
|
||||
case 'active':
|
||||
return {
|
||||
color: 'bg-neon-500/20 text-neon-400 border-neon-500/30',
|
||||
text: 'Активен',
|
||||
dot: 'bg-neon-500 animate-pulse',
|
||||
}
|
||||
case 'finished':
|
||||
return {
|
||||
color: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||
text: 'Завершён',
|
||||
dot: 'bg-gray-500',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
color: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||
text: status,
|
||||
dot: 'bg-gray-500',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const status = getStatusConfig(marathon.status)
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto">
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="flex items-center justify-center gap-2">
|
||||
<Trophy className="w-6 h-6 text-primary-500" />
|
||||
Приглашение в марафон
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Marathon info */}
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-white mb-2">{marathon.title}</h2>
|
||||
{marathon.description && (
|
||||
<p className="text-gray-400 text-sm mb-4">{marathon.description}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-4 text-sm text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="w-4 h-4" />
|
||||
{marathon.participants_count} участников
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
marathon.status === 'active' ? 'bg-green-900/50 text-green-400' :
|
||||
marathon.status === 'preparing' ? 'bg-yellow-900/50 text-yellow-400' :
|
||||
'bg-gray-700 text-gray-400'
|
||||
}`}>
|
||||
{statusText}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-500 text-xs mt-2">
|
||||
Организатор: {marathon.creator_nickname}
|
||||
</p>
|
||||
<div className="min-h-[70vh] flex items-center justify-center px-4">
|
||||
{/* Background effects */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-1/4 -left-32 w-96 h-96 bg-neon-500/10 rounded-full blur-[100px]" />
|
||||
<div className="absolute bottom-1/4 -right-32 w-96 h-96 bg-accent-500/10 rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
<div className="relative w-full max-w-md">
|
||||
<GlassCard variant="neon" className="animate-scale-in">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 border border-neon-500/30 flex items-center justify-center">
|
||||
<Trophy className="w-10 h-10 text-neon-400" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-white mb-1">Приглашение в марафон</h1>
|
||||
<p className="text-gray-400 text-sm">Вас пригласили присоединиться</p>
|
||||
</div>
|
||||
|
||||
{/* Marathon info */}
|
||||
<div className="glass rounded-xl p-5 mb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center border border-neon-500/20 flex-shrink-0">
|
||||
<Gamepad2 className="w-7 h-7 text-neon-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl font-bold text-white mb-1 truncate">{marathon.title}</h2>
|
||||
{marathon.description && (
|
||||
<p className="text-gray-400 text-sm line-clamp-2 mb-3">{marathon.description}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium border flex items-center gap-1.5 ${status.color}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${status.dot}`} />
|
||||
{status.text}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-sm text-gray-400">
|
||||
<Users className="w-4 h-4" />
|
||||
{marathon.participants_count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organizer */}
|
||||
<div className="mt-4 pt-4 border-t border-dark-600 flex items-center gap-2 text-sm text-gray-500">
|
||||
<Crown className="w-4 h-4 text-yellow-500" />
|
||||
<span>Организатор:</span>
|
||||
<span className="text-gray-300">{marathon.creator_nickname}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{marathon.status === 'finished' ? (
|
||||
<div className="text-center text-gray-400">
|
||||
Этот марафон уже завершён
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-gray-500/10 flex items-center justify-center">
|
||||
<AlertCircle className="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-gray-400 mb-4">Этот марафон уже завершён</p>
|
||||
<Link to="/marathons">
|
||||
<NeonButton variant="outline" className="w-full">
|
||||
К списку марафонов
|
||||
</NeonButton>
|
||||
</Link>
|
||||
</div>
|
||||
) : isAuthenticated ? (
|
||||
/* Authenticated - show join button */
|
||||
<Button
|
||||
<NeonButton
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={handleJoin}
|
||||
isLoading={isJoining}
|
||||
icon={<Sparkles className="w-5 h-5" />}
|
||||
>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
Присоединиться к марафону
|
||||
</Button>
|
||||
Присоединиться
|
||||
</NeonButton>
|
||||
) : (
|
||||
/* Not authenticated - show login/register options */
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
<p className="text-center text-gray-400 text-sm">
|
||||
Чтобы присоединиться, войдите или зарегистрируйтесь
|
||||
</p>
|
||||
<Button
|
||||
<NeonButton
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={() => handleAuthRedirect('/login')}
|
||||
icon={<LogIn className="w-5 h-5" />}
|
||||
>
|
||||
<LogIn className="w-4 h-4 mr-2" />
|
||||
Войти
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => handleAuthRedirect('/register')}
|
||||
icon={<UserPlus className="w-5 h-5" />}
|
||||
>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
Зарегистрироваться
|
||||
</Button>
|
||||
</NeonButton>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute -top-4 -left-4 w-24 h-24 border border-neon-500/20 rounded-2xl -z-10" />
|
||||
<div className="absolute -bottom-4 -right-4 w-32 h-32 border border-accent-500/20 rounded-2xl -z-10" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useState, useEffect } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { marathonsApi } from '@/api'
|
||||
import type { LeaderboardEntry } from '@/types'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { GlassCard } from '@/components/ui'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { Trophy, Flame, ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import { Trophy, Flame, ArrowLeft, Loader2, Crown, Medal, Award, Star, Zap, Target } from 'lucide-react'
|
||||
|
||||
export function LeaderboardPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
@@ -28,92 +28,257 @@ export function LeaderboardPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const getRankIcon = (rank: number) => {
|
||||
const getRankConfig = (rank: number) => {
|
||||
switch (rank) {
|
||||
case 1:
|
||||
return <Trophy className="w-6 h-6 text-yellow-500" />
|
||||
return {
|
||||
icon: <Crown className="w-6 h-6" />,
|
||||
color: 'text-yellow-400',
|
||||
bg: 'bg-yellow-500/20',
|
||||
border: 'border-yellow-500/30',
|
||||
glow: 'shadow-[0_0_20px_rgba(234,179,8,0.3)]',
|
||||
gradient: 'from-yellow-500/20 via-transparent to-transparent',
|
||||
}
|
||||
case 2:
|
||||
return <Trophy className="w-6 h-6 text-gray-400" />
|
||||
return {
|
||||
icon: <Medal className="w-6 h-6" />,
|
||||
color: 'text-gray-300',
|
||||
bg: 'bg-gray-400/20',
|
||||
border: 'border-gray-400/30',
|
||||
glow: 'shadow-[0_0_15px_rgba(156,163,175,0.2)]',
|
||||
gradient: 'from-gray-400/10 via-transparent to-transparent',
|
||||
}
|
||||
case 3:
|
||||
return <Trophy className="w-6 h-6 text-amber-700" />
|
||||
return {
|
||||
icon: <Award className="w-6 h-6" />,
|
||||
color: 'text-amber-600',
|
||||
bg: 'bg-amber-600/20',
|
||||
border: 'border-amber-600/30',
|
||||
glow: 'shadow-[0_0_15px_rgba(217,119,6,0.2)]',
|
||||
gradient: 'from-amber-600/10 via-transparent to-transparent',
|
||||
}
|
||||
default:
|
||||
return <span className="text-gray-500 font-mono w-6 text-center">{rank}</span>
|
||||
return {
|
||||
icon: <span className="text-gray-500 font-mono font-bold">{rank}</span>,
|
||||
color: 'text-gray-500',
|
||||
bg: 'bg-dark-700',
|
||||
border: 'border-dark-600',
|
||||
glow: '',
|
||||
gradient: '',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Top 3 for podium
|
||||
const topThree = leaderboard.slice(0, 3)
|
||||
|
||||
// Calculate stats
|
||||
const totalPoints = leaderboard.reduce((acc, e) => acc + e.total_points, 0)
|
||||
const maxStreak = Math.max(...leaderboard.map(e => e.current_streak), 0)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
|
||||
<p className="text-gray-400">Загрузка рейтинга...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<Link to={`/marathons/${id}`} className="text-gray-400 hover:text-white">
|
||||
<ArrowLeft className="w-6 h-6" />
|
||||
<Link
|
||||
to={`/marathons/${id}`}
|
||||
className="w-10 h-10 rounded-xl bg-dark-700 border border-dark-600 flex items-center justify-center text-gray-400 hover:text-white hover:border-neon-500/30 transition-all"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Таблица лидеров</h1>
|
||||
<p className="text-gray-400 text-sm">Рейтинг участников марафона</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Trophy className="w-5 h-5 text-yellow-500" />
|
||||
Рейтинг
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{leaderboard.length === 0 ? (
|
||||
<p className="text-center text-gray-400 py-8">Пока нет участников</p>
|
||||
<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">
|
||||
{leaderboard.map((entry) => (
|
||||
{leaderboard.map((entry, index) => {
|
||||
const isCurrentUser = entry.user.id === user?.id
|
||||
const rankConfig = getRankConfig(entry.rank)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.user.id}
|
||||
className={`flex items-center gap-4 p-4 rounded-lg ${
|
||||
entry.user.id === user?.id
|
||||
? 'bg-primary-500/20 border border-primary-500/50'
|
||||
: 'bg-gray-900'
|
||||
}`}
|
||||
className={`
|
||||
relative flex items-center gap-4 p-4 rounded-xl
|
||||
transition-all duration-300 group
|
||||
${isCurrentUser
|
||||
? 'bg-neon-500/10 border border-neon-500/30 shadow-[0_0_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">
|
||||
{getRankIcon(entry.rank)}
|
||||
{/* Gradient overlay for top 3 */}
|
||||
{entry.rank <= 3 && (
|
||||
<div className={`absolute inset-0 bg-gradient-to-r ${rankConfig.gradient} rounded-xl pointer-events-none`} />
|
||||
)}
|
||||
|
||||
{/* Rank */}
|
||||
<div className={`
|
||||
relative w-10 h-10 rounded-xl flex items-center justify-center
|
||||
${rankConfig.bg} ${rankConfig.color} ${rankConfig.glow}
|
||||
`}>
|
||||
{rankConfig.icon}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-white">
|
||||
{/* User info */}
|
||||
<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.id === user?.id && (
|
||||
<span className="ml-2 text-xs text-primary-400">(Вы)</span>
|
||||
</Link>
|
||||
{isCurrentUser && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-neon-500/20 text-neon-400 rounded-full border border-neon-500/30">
|
||||
Вы
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{entry.completed_count} выполнено, {entry.dropped_count} пропущено
|
||||
<div className="flex items-center gap-3 text-sm text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 bg-green-500 rounded-full" />
|
||||
{entry.completed_count} выполнено
|
||||
</span>
|
||||
{entry.dropped_count > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 bg-red-500 rounded-full" />
|
||||
{entry.dropped_count} пропущено
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Streak */}
|
||||
{entry.current_streak > 0 && (
|
||||
<div className="flex items-center gap-1 text-yellow-500">
|
||||
<Flame className="w-4 h-4" />
|
||||
<span className="text-sm">{entry.current_streak}</span>
|
||||
<div className="relative flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-orange-500/10 border border-orange-500/20">
|
||||
<Flame className="w-4 h-4 text-orange-400" />
|
||||
<span className="text-sm font-semibold text-orange-400">{entry.current_streak}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-right">
|
||||
<div className="text-xl font-bold text-primary-400">
|
||||
{/* Points */}
|
||||
<div className="relative text-right">
|
||||
<div className={`text-xl font-bold ${isCurrentUser ? 'text-neon-400' : 'text-white'}`}>
|
||||
{entry.total_points}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">очков</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { marathonsApi, gamesApi } from '@/api'
|
||||
import type { Marathon, Game, Challenge, ChallengePreview } from '@/types'
|
||||
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { NeonButton, Input, GlassCard, StatsCard } from '@/components/ui'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { useConfirm } from '@/store/confirm'
|
||||
import {
|
||||
Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye,
|
||||
ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft
|
||||
ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft, Zap
|
||||
} from 'lucide-react'
|
||||
|
||||
export function LobbyPage() {
|
||||
@@ -39,6 +39,8 @@ export function LobbyPage() {
|
||||
const [previewChallenges, setPreviewChallenges] = useState<ChallengePreview[] | null>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
||||
const [showGenerateSelection, setShowGenerateSelection] = useState(false)
|
||||
const [selectedGamesForGeneration, setSelectedGamesForGeneration] = useState<number[]>([])
|
||||
|
||||
// View existing challenges
|
||||
const [expandedGameId, setExpandedGameId] = useState<number | null>(null)
|
||||
@@ -72,17 +74,14 @@ export function LobbyPage() {
|
||||
const marathonData = await marathonsApi.get(parseInt(id))
|
||||
setMarathon(marathonData)
|
||||
|
||||
// Load games - organizers see all, participants see approved + own
|
||||
const gamesData = await gamesApi.list(parseInt(id))
|
||||
setGames(gamesData)
|
||||
|
||||
// If organizer, load pending games separately
|
||||
if (marathonData.my_participation?.role === 'organizer' || user?.role === 'admin') {
|
||||
try {
|
||||
const pending = await gamesApi.listPending(parseInt(id))
|
||||
setPendingGames(pending)
|
||||
} catch {
|
||||
// If not authorized, just ignore
|
||||
setPendingGames([])
|
||||
}
|
||||
}
|
||||
@@ -175,7 +174,6 @@ export function LobbyPage() {
|
||||
|
||||
setExpandedGameId(gameId)
|
||||
|
||||
// Load challenges if we haven't loaded them yet
|
||||
if (!gameChallenges[gameId]) {
|
||||
setLoadingChallenges(gameId)
|
||||
try {
|
||||
@@ -183,7 +181,6 @@ export function LobbyPage() {
|
||||
setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
|
||||
} catch (error) {
|
||||
console.error('Failed to load challenges:', error)
|
||||
// Set empty array to prevent repeated attempts
|
||||
setGameChallenges(prev => ({ ...prev, [gameId]: [] }))
|
||||
} finally {
|
||||
setLoadingChallenges(null)
|
||||
@@ -210,7 +207,6 @@ export function LobbyPage() {
|
||||
proof_hint: newChallenge.proof_hint.trim() || undefined,
|
||||
})
|
||||
toast.success('Задание добавлено')
|
||||
// Reset form
|
||||
setNewChallenge({
|
||||
title: '',
|
||||
description: '',
|
||||
@@ -222,7 +218,6 @@ export function LobbyPage() {
|
||||
proof_hint: '',
|
||||
})
|
||||
setAddingChallengeToGameId(null)
|
||||
// Refresh challenges
|
||||
const challenges = await gamesApi.getChallenges(gameId)
|
||||
setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
|
||||
await loadData()
|
||||
@@ -246,10 +241,9 @@ export function LobbyPage() {
|
||||
|
||||
try {
|
||||
await gamesApi.deleteChallenge(challengeId)
|
||||
// Refresh challenges for this game
|
||||
const challenges = await gamesApi.getChallenges(gameId)
|
||||
setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
|
||||
await loadData() // Refresh game counts
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete challenge:', error)
|
||||
}
|
||||
@@ -261,11 +255,14 @@ export function LobbyPage() {
|
||||
setIsGenerating(true)
|
||||
setGenerateMessage(null)
|
||||
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) {
|
||||
setGenerateMessage('Все игры уже имеют задания')
|
||||
setGenerateMessage('Нет игр для генерации заданий')
|
||||
} else {
|
||||
setPreviewChallenges(result.challenges)
|
||||
setShowGenerateSelection(false)
|
||||
}
|
||||
} catch (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 () => {
|
||||
if (!id || !previewChallenges) return
|
||||
|
||||
@@ -283,7 +296,7 @@ export function LobbyPage() {
|
||||
const result = await gamesApi.saveChallenges(parseInt(id), previewChallenges)
|
||||
setGenerateMessage(result.message)
|
||||
setPreviewChallenges(null)
|
||||
setGameChallenges({}) // Clear cache to reload
|
||||
setGameChallenges({})
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
console.error('Failed to save challenges:', error)
|
||||
@@ -337,8 +350,9 @@ export function LobbyPage() {
|
||||
|
||||
if (isLoading || !marathon) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
|
||||
<p className="text-gray-400">Загрузка лобби...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -351,21 +365,21 @@ export function LobbyPage() {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-green-900/50 text-green-400">
|
||||
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-lg bg-green-500/20 text-green-400 border border-green-500/30">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Одобрено
|
||||
</span>
|
||||
)
|
||||
case 'pending':
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-yellow-900/50 text-yellow-400">
|
||||
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-lg bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">
|
||||
<Clock className="w-3 h-3" />
|
||||
На модерации
|
||||
</span>
|
||||
)
|
||||
case 'rejected':
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-red-900/50 text-red-400">
|
||||
<span className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-lg bg-red-500/20 text-red-400 border border-red-500/30">
|
||||
<XCircle className="w-3 h-3" />
|
||||
Отклонено
|
||||
</span>
|
||||
@@ -376,11 +390,11 @@ export function LobbyPage() {
|
||||
}
|
||||
|
||||
const renderGameCard = (game: Game, showModeration = false) => (
|
||||
<div key={game.id} className="bg-gray-900 rounded-lg overflow-hidden">
|
||||
<div key={game.id} className="glass rounded-xl overflow-hidden border border-dark-600">
|
||||
{/* Game header */}
|
||||
<div
|
||||
className={`flex items-center justify-between p-4 ${
|
||||
(game.status === 'approved') ? 'cursor-pointer hover:bg-gray-800/50' : ''
|
||||
(game.status === 'approved') ? 'cursor-pointer hover:bg-dark-700/50 transition-colors' : ''
|
||||
}`}
|
||||
onClick={() => game.status === 'approved' && handleToggleGameChallenges(game.id)}
|
||||
>
|
||||
@@ -394,14 +408,22 @@ export function LobbyPage() {
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-neon-500/20 to-accent-500/20 border border-neon-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Gamepad2 className="w-5 h-5 text-neon-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="font-medium text-white">{game.title}</h4>
|
||||
<h4 className="font-semibold text-white">{game.title}</h4>
|
||||
{getStatusBadge(game.status)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 flex items-center gap-3 flex-wrap">
|
||||
{game.genre && <span>{game.genre}</span>}
|
||||
{game.status === 'approved' && <span>{game.challenges_count} заданий</span>}
|
||||
{game.status === 'approved' && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3 text-accent-400" />
|
||||
{game.challenges_count} заданий
|
||||
</span>
|
||||
)}
|
||||
{game.proposed_by && (
|
||||
<span className="flex items-center gap-1 text-gray-500">
|
||||
<User className="w-3 h-3" />
|
||||
@@ -414,49 +436,43 @@ export function LobbyPage() {
|
||||
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
{showModeration && game.status === 'pending' && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
<button
|
||||
onClick={() => handleApproveGame(game.id)}
|
||||
disabled={moderatingGameId === game.id}
|
||||
className="text-green-400 hover:text-green-300"
|
||||
className="p-2 rounded-lg text-green-400 hover:bg-green-500/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{moderatingGameId === game.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRejectGame(game.id)}
|
||||
disabled={moderatingGameId === game.id}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
className="p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
</Button>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(isOrganizer || game.proposed_by?.id === user?.id) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
<button
|
||||
onClick={() => handleDeleteGame(game.id)}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
className="p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded challenges list */}
|
||||
{expandedGameId === game.id && (
|
||||
<div className="border-t border-gray-800 p-4 space-y-2">
|
||||
<div className="border-t border-dark-600 p-4 space-y-2 bg-dark-800/30">
|
||||
{loadingChallenges === game.id ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-gray-400" />
|
||||
<Loader2 className="w-5 h-5 animate-spin text-neon-400" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -464,24 +480,24 @@ export function LobbyPage() {
|
||||
gameChallenges[game.id].map((challenge) => (
|
||||
<div
|
||||
key={challenge.id}
|
||||
className="flex items-start justify-between gap-3 p-3 bg-gray-800 rounded-lg"
|
||||
className="flex items-start justify-between gap-3 p-3 bg-dark-700/50 rounded-lg border border-dark-600"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
challenge.difficulty === 'easy' ? 'bg-green-900/50 text-green-400' :
|
||||
challenge.difficulty === 'medium' ? 'bg-yellow-900/50 text-yellow-400' :
|
||||
'bg-red-900/50 text-red-400'
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
|
||||
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
|
||||
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
|
||||
'bg-red-500/20 text-red-400 border-red-500/30'
|
||||
}`}>
|
||||
{challenge.difficulty === 'easy' ? 'Легко' :
|
||||
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
|
||||
</span>
|
||||
<span className="text-xs text-primary-400 font-medium">
|
||||
<span className="text-xs text-neon-400 font-semibold">
|
||||
+{challenge.points}
|
||||
</span>
|
||||
{challenge.is_generated && (
|
||||
<span className="text-xs text-gray-500">
|
||||
<Sparkles className="w-3 h-3 inline" /> ИИ
|
||||
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" /> ИИ
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -489,19 +505,17 @@ export function LobbyPage() {
|
||||
<p className="text-xs text-gray-400 mt-1">{challenge.description}</p>
|
||||
</div>
|
||||
{isOrganizer && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
<button
|
||||
onClick={() => handleDeleteChallenge(challenge.id, game.id)}
|
||||
className="text-red-400 hover:text-red-300 shrink-0"
|
||||
className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors shrink-0"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-center text-gray-500 py-2 text-sm">
|
||||
<p className="text-center text-gray-500 py-4 text-sm">
|
||||
Нет заданий
|
||||
</p>
|
||||
)}
|
||||
@@ -509,8 +523,11 @@ export function LobbyPage() {
|
||||
{/* Add challenge form */}
|
||||
{isOrganizer && game.status === 'approved' && (
|
||||
addingChallengeToGameId === game.id ? (
|
||||
<div className="mt-4 p-4 bg-gray-800 rounded-lg space-y-3 border border-gray-700">
|
||||
<h4 className="font-medium text-white text-sm">Новое задание</h4>
|
||||
<div className="mt-4 p-4 glass rounded-xl space-y-3 border border-neon-500/20">
|
||||
<h4 className="font-semibold text-white text-sm flex items-center gap-2">
|
||||
<Plus className="w-4 h-4 text-neon-400" />
|
||||
Новое задание
|
||||
</h4>
|
||||
<Input
|
||||
placeholder="Название задания"
|
||||
value={newChallenge.title}
|
||||
@@ -520,7 +537,7 @@ export function LobbyPage() {
|
||||
placeholder="Описание (что нужно сделать)"
|
||||
value={newChallenge.description}
|
||||
onChange={(e) => setNewChallenge(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm resize-none"
|
||||
className="input w-full resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -529,7 +546,7 @@ export function LobbyPage() {
|
||||
<select
|
||||
value={newChallenge.type}
|
||||
onChange={(e) => setNewChallenge(prev => ({ ...prev, type: e.target.value }))}
|
||||
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm"
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="completion">Прохождение</option>
|
||||
<option value="no_death">Без смертей</option>
|
||||
@@ -544,7 +561,7 @@ export function LobbyPage() {
|
||||
<select
|
||||
value={newChallenge.difficulty}
|
||||
onChange={(e) => setNewChallenge(prev => ({ ...prev, difficulty: e.target.value }))}
|
||||
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm"
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="easy">Легко (20-40 очков)</option>
|
||||
<option value="medium">Средне (45-75 очков)</option>
|
||||
@@ -575,11 +592,11 @@ export function LobbyPage() {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Тип доказательства</label>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Тип пруфа</label>
|
||||
<select
|
||||
value={newChallenge.proof_type}
|
||||
onChange={(e) => setNewChallenge(prev => ({ ...prev, proof_type: e.target.value }))}
|
||||
className="w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-white text-sm"
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="screenshot">Скриншот</option>
|
||||
<option value="video">Видео</option>
|
||||
@@ -589,44 +606,42 @@ export function LobbyPage() {
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 mb-1 block">Подсказка</label>
|
||||
<Input
|
||||
placeholder="Что должно быть на пруфе"
|
||||
placeholder="Что на пруфе"
|
||||
value={newChallenge.proof_hint}
|
||||
onChange={(e) => setNewChallenge(prev => ({ ...prev, proof_hint: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
<NeonButton
|
||||
size="sm"
|
||||
onClick={() => handleCreateChallenge(game.id)}
|
||||
isLoading={isCreatingChallenge}
|
||||
disabled={!newChallenge.title || !newChallenge.description}
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
Добавить
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAddingChallengeToGameId(null)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
<button
|
||||
onClick={() => {
|
||||
setAddingChallengeToGameId(game.id)
|
||||
setExpandedGameId(game.id)
|
||||
}}
|
||||
className="w-full mt-2 border border-dashed border-gray-700 text-gray-400 hover:text-white hover:border-gray-600"
|
||||
className="w-full mt-2 p-3 rounded-lg border-2 border-dashed border-dark-600 text-gray-400 hover:text-neon-400 hover:border-neon-500/30 transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
<Plus className="w-4 h-4" />
|
||||
Добавить задание вручную
|
||||
</Button>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
@@ -639,14 +654,18 @@ export function LobbyPage() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Back button */}
|
||||
<Link to={`/marathons/${id}`} className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<Link
|
||||
to={`/marathons/${id}`}
|
||||
className="inline-flex items-center gap-2 text-gray-400 hover:text-neon-400 mb-6 transition-colors group"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
|
||||
К марафону
|
||||
</Link>
|
||||
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">{marathon.title}</h1>
|
||||
<h1 className="text-2xl font-bold text-white mb-1">{marathon.title}</h1>
|
||||
<p className="text-gray-400">
|
||||
{isOrganizer
|
||||
? 'Настройка - Добавьте игры и сгенерируйте задания'
|
||||
@@ -655,130 +674,216 @@ export function LobbyPage() {
|
||||
</div>
|
||||
|
||||
{isOrganizer && (
|
||||
<Button onClick={handleStartMarathon} isLoading={isStarting} disabled={approvedGames.length === 0}>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
<NeonButton
|
||||
onClick={handleStartMarathon}
|
||||
isLoading={isStarting}
|
||||
disabled={approvedGames.length === 0}
|
||||
icon={<Play className="w-4 h-4" />}
|
||||
>
|
||||
Запустить марафон
|
||||
</Button>
|
||||
</NeonButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats - только для организаторов */}
|
||||
{/* Stats */}
|
||||
{isOrganizer && (
|
||||
<div className="grid grid-cols-2 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardContent className="text-center py-4">
|
||||
<div className="text-2xl font-bold text-white">{approvedGames.length}</div>
|
||||
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
||||
<Gamepad2 className="w-4 h-4" />
|
||||
Игр одобрено
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="text-center py-4">
|
||||
<div className="text-2xl font-bold text-white">{totalChallenges}</div>
|
||||
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Заданий
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<StatsCard
|
||||
label="Игр одобрено"
|
||||
value={approvedGames.length}
|
||||
icon={<Gamepad2 className="w-6 h-6" />}
|
||||
color="neon"
|
||||
/>
|
||||
<StatsCard
|
||||
label="Заданий"
|
||||
value={totalChallenges}
|
||||
icon={<Sparkles className="w-6 h-6" />}
|
||||
color="purple"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending games for moderation (organizers only) */}
|
||||
{/* Pending games for moderation */}
|
||||
{isOrganizer && pendingGames.length > 0 && (
|
||||
<Card className="mb-8 border-yellow-900/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-yellow-400">
|
||||
<Clock className="w-5 h-5" />
|
||||
На модерации ({pendingGames.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<GlassCard className="mb-8 border-yellow-500/30">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center">
|
||||
<Clock className="w-5 h-5 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-yellow-400">На модерации</h3>
|
||||
<p className="text-sm text-gray-400">{pendingGames.length} игр ожидают</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{pendingGames.map((game) => renderGameCard(game, true))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Generate challenges button */}
|
||||
{/* Generate challenges */}
|
||||
{isOrganizer && approvedGames.length > 0 && !previewChallenges && (
|
||||
<Card className="mb-8">
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<GlassCard className="mb-8">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap mb-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">
|
||||
<Zap className="w-5 h-5 text-accent-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white">Генерация заданий</h3>
|
||||
<h3 className="font-semibold text-white">Генерация заданий</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Используйте ИИ для генерации заданий для одобренных игр без заданий
|
||||
{showGenerateSelection
|
||||
? `Выбрано: ${selectedGamesForGeneration.length} из ${approvedGames.length}`
|
||||
: 'Выберите игры для генерации'}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleGenerateChallenges} isLoading={isGenerating} variant="secondary">
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
Сгенерировать
|
||||
</Button>
|
||||
</div>
|
||||
{generateMessage && (
|
||||
<p className="mt-3 text-sm text-primary-400">{generateMessage}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 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 className="flex gap-2">
|
||||
<Button onClick={handleCancelPreview} variant="ghost" size="sm">
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
{showGenerateSelection ? (
|
||||
<>
|
||||
<NeonButton
|
||||
onClick={() => {
|
||||
setShowGenerateSelection(false)
|
||||
clearGameSelection()
|
||||
}}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button onClick={handleSaveChallenges} isLoading={isSaving} size="sm">
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
Сохранить все
|
||||
</Button>
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
onClick={handleGenerateChallenges}
|
||||
isLoading={isGenerating}
|
||||
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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto">
|
||||
</div>
|
||||
|
||||
{/* 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) => (
|
||||
<div
|
||||
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 ? (
|
||||
// Edit mode
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-800 text-gray-400">
|
||||
<span className="text-xs px-2 py-0.5 rounded-lg bg-dark-600 text-gray-400">
|
||||
{challenge.game_title}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
value={challenge.title}
|
||||
onChange={(e) => handleUpdatePreviewChallenge(index, 'title', e.target.value)}
|
||||
placeholder="Название"
|
||||
className="bg-gray-800"
|
||||
/>
|
||||
<textarea
|
||||
value={challenge.description}
|
||||
onChange={(e) => handleUpdatePreviewChallenge(index, 'description', e.target.value)}
|
||||
placeholder="Описание"
|
||||
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm resize-none"
|
||||
className="input w-full resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<select
|
||||
value={challenge.difficulty}
|
||||
onChange={(e) => handleUpdatePreviewChallenge(index, 'difficulty', e.target.value)}
|
||||
className="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="medium">Средне</option>
|
||||
@@ -789,12 +894,11 @@ export function LobbyPage() {
|
||||
value={challenge.points}
|
||||
onChange={(e) => handleUpdatePreviewChallenge(index, 'points', parseInt(e.target.value) || 0)}
|
||||
placeholder="Очки"
|
||||
className="bg-gray-800"
|
||||
/>
|
||||
<select
|
||||
value={challenge.proof_type}
|
||||
onChange={(e) => handleUpdatePreviewChallenge(index, 'proof_type', e.target.value)}
|
||||
className="px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white text-sm"
|
||||
className="input"
|
||||
>
|
||||
<option value="screenshot">Скриншот</option>
|
||||
<option value="video">Видео</option>
|
||||
@@ -804,42 +908,39 @@ export function LobbyPage() {
|
||||
<Input
|
||||
value={challenge.proof_hint || ''}
|
||||
onChange={(e) => handleUpdatePreviewChallenge(index, 'proof_hint', e.target.value)}
|
||||
placeholder="Подсказка для подтверждения"
|
||||
className="bg-gray-800"
|
||||
placeholder="Подсказка для пруфа"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => setEditingIndex(null)}>
|
||||
<Check className="w-4 h-4 mr-1" />
|
||||
<NeonButton size="sm" onClick={() => setEditingIndex(null)} icon={<Check className="w-4 h-4" />}>
|
||||
Готово
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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>
|
||||
) : (
|
||||
// View mode
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-800 text-gray-400">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="text-xs px-2 py-0.5 rounded-lg bg-dark-600 text-gray-400">
|
||||
{challenge.game_title}
|
||||
</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
challenge.difficulty === 'easy' ? 'bg-green-900/50 text-green-400' :
|
||||
challenge.difficulty === 'medium' ? 'bg-yellow-900/50 text-yellow-400' :
|
||||
'bg-red-900/50 text-red-400'
|
||||
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
|
||||
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
|
||||
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
|
||||
'bg-red-500/20 text-red-400 border-red-500/30'
|
||||
}`}>
|
||||
{challenge.difficulty === 'easy' ? 'Легко' :
|
||||
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
|
||||
</span>
|
||||
<span className="text-xs text-primary-400 font-medium">
|
||||
<span className="text-xs text-neon-400 font-semibold">
|
||||
+{challenge.points} очков
|
||||
</span>
|
||||
</div>
|
||||
@@ -847,53 +948,55 @@ export function LobbyPage() {
|
||||
<p className="text-sm text-gray-400">{challenge.description}</p>
|
||||
{challenge.proof_hint && (
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Подтверждение: {challenge.proof_hint}
|
||||
Пруф: {challenge.proof_hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
<button
|
||||
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" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
</button>
|
||||
<button
|
||||
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" />
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Games list */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Игры</CardTitle>
|
||||
{/* Показываем кнопку если: all_participants ИЛИ (organizer_only И isOrganizer) */}
|
||||
<GlassCard>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-neon-500/20 flex items-center justify-center">
|
||||
<Gamepad2 className="w-5 h-5 text-neon-400" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-white">Игры</h3>
|
||||
</div>
|
||||
{(marathon.game_proposal_mode === 'all_participants' || isOrganizer) && (
|
||||
<Button size="sm" onClick={() => setShowAddGame(!showAddGame)}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{isOrganizer ? 'Добавить игру' : 'Предложить игру'}
|
||||
</Button>
|
||||
<NeonButton
|
||||
size="sm"
|
||||
onClick={() => setShowAddGame(!showAddGame)}
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
>
|
||||
{isOrganizer ? 'Добавить' : 'Предложить'}
|
||||
</NeonButton>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
</div>
|
||||
|
||||
{/* Add game form */}
|
||||
{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
|
||||
placeholder="Название игры"
|
||||
value={gameTitle}
|
||||
@@ -910,16 +1013,20 @@ export function LobbyPage() {
|
||||
onChange={(e) => setGameGenre(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleAddGame} isLoading={isAddingGame} disabled={!gameTitle || !gameUrl}>
|
||||
<NeonButton
|
||||
onClick={handleAddGame}
|
||||
isLoading={isAddingGame}
|
||||
disabled={!gameTitle || !gameUrl}
|
||||
>
|
||||
{isOrganizer ? 'Добавить' : 'Предложить'}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setShowAddGame(false)}>
|
||||
</NeonButton>
|
||||
<NeonButton variant="outline" onClick={() => setShowAddGame(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
</NeonButton>
|
||||
</div>
|
||||
{!isOrganizer && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Ваша игра будет отправлена на модерацию организаторам
|
||||
Игра будет отправлена на модерацию организаторам
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -927,24 +1034,26 @@ export function LobbyPage() {
|
||||
|
||||
{/* Games */}
|
||||
{(() => {
|
||||
// Организаторы: показываем только одобренные (pending в секции модерации)
|
||||
// Участники: показываем одобренные + свои pending
|
||||
const visibleGames = isOrganizer
|
||||
? games.filter(g => g.status !== 'pending')
|
||||
: games.filter(g => g.status === 'approved' || (g.status === 'pending' && g.proposed_by?.id === user?.id))
|
||||
|
||||
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 ? 'Пока нет игр. Добавьте игры, чтобы начать!' : 'Пока нет одобренных игр. Предложите свою!'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{visibleGames.map((game) => renderGameCard(game, false))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { marathonsApi } from '@/api'
|
||||
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui'
|
||||
import { NeonButton, Input, 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 символов'),
|
||||
@@ -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 (
|
||||
<div className="max-w-md mx-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center">Вход</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="min-h-[80vh] flex items-center justify-center px-4 -mt-8">
|
||||
{/* Background effects */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-1/4 -left-32 w-96 h-96 bg-neon-500/10 rounded-full blur-[100px]" />
|
||||
<div className="absolute bottom-1/4 -right-32 w-96 h-96 bg-accent-500/10 rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
{/* 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) && (
|
||||
<div className="p-3 bg-red-500/20 border border-red-500 rounded-lg text-red-500 text-sm">
|
||||
{submitError || error}
|
||||
<div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||
<span>{submitError || error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -69,6 +132,7 @@ export function LoginPage() {
|
||||
label="Логин"
|
||||
placeholder="Введите логин"
|
||||
error={errors.login?.message}
|
||||
autoComplete="username"
|
||||
{...register('login')}
|
||||
/>
|
||||
|
||||
@@ -77,22 +141,40 @@ export function LoginPage() {
|
||||
type="password"
|
||||
placeholder="Введите пароль"
|
||||
error={errors.password?.message}
|
||||
autoComplete="current-password"
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={isLoading}>
|
||||
<NeonButton
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
icon={<LogIn className="w-5 h-5" />}
|
||||
>
|
||||
Войти
|
||||
</Button>
|
||||
</NeonButton>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-gray-400 text-sm">
|
||||
{/* Footer */}
|
||||
<div className="mt-6 pt-6 border-t border-dark-600 text-center">
|
||||
<p className="text-gray-400 text-sm">
|
||||
Нет аккаунта?{' '}
|
||||
<Link to="/register" className="link">
|
||||
<Link
|
||||
to="/register"
|
||||
className="text-neon-400 hover:text-neon-300 transition-colors font-medium"
|
||||
>
|
||||
Зарегистрироваться
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,15 +2,20 @@ import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { marathonsApi, eventsApi, challengesApi } from '@/api'
|
||||
import type { Marathon, ActiveEvent, Challenge } from '@/types'
|
||||
import { Button, Card, CardContent } from '@/components/ui'
|
||||
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useToast } from '@/store/toast'
|
||||
import { useConfirm } from '@/store/confirm'
|
||||
import { EventBanner } from '@/components/EventBanner'
|
||||
import { EventControl } from '@/components/EventControl'
|
||||
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
|
||||
import { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2, Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag } from 'lucide-react'
|
||||
import {
|
||||
Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2,
|
||||
Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag,
|
||||
Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2, Sparkles
|
||||
} from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
|
||||
export function MarathonPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
@@ -27,6 +32,8 @@ export function MarathonPage() {
|
||||
const [isJoining, setIsJoining] = useState(false)
|
||||
const [isFinishing, setIsFinishing] = useState(false)
|
||||
const [showEventControl, setShowEventControl] = useState(false)
|
||||
const [showChallenges, setShowChallenges] = useState(false)
|
||||
const [expandedGameId, setExpandedGameId] = useState<number | null>(null)
|
||||
const activityFeedRef = useRef<ActivityFeedRef>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -39,13 +46,11 @@ export function MarathonPage() {
|
||||
const data = await marathonsApi.get(parseInt(id))
|
||||
setMarathon(data)
|
||||
|
||||
// Load event data if marathon is active
|
||||
if (data.status === 'active' && data.my_participation) {
|
||||
const eventData = await eventsApi.getActive(parseInt(id))
|
||||
setActiveEvent(eventData)
|
||||
|
||||
// Load challenges for event control if organizer
|
||||
if (data.my_participation.role === 'organizer') {
|
||||
// Load challenges for all participants
|
||||
try {
|
||||
const challengesData = await challengesApi.list(parseInt(id))
|
||||
setChallenges(challengesData)
|
||||
@@ -53,7 +58,6 @@ export function MarathonPage() {
|
||||
// Ignore if no challenges
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load marathon:', error)
|
||||
navigate('/marathons')
|
||||
@@ -67,7 +71,6 @@ export function MarathonPage() {
|
||||
try {
|
||||
const eventData = await eventsApi.getActive(parseInt(id))
|
||||
setActiveEvent(eventData)
|
||||
// Refresh activity feed when event changes
|
||||
activityFeedRef.current?.refresh()
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh event:', error)
|
||||
@@ -153,8 +156,9 @@ export function MarathonPage() {
|
||||
|
||||
if (isLoading || !marathon) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
|
||||
<p className="text-gray-400">Загрузка марафона...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -164,265 +168,358 @@ export function MarathonPage() {
|
||||
const isCreator = marathon.creator.id === user?.id
|
||||
const canDelete = isCreator || user?.role === 'admin'
|
||||
|
||||
const statusConfig = {
|
||||
active: { color: 'text-neon-400', bg: 'bg-neon-500/20', border: 'border-neon-500/30', label: 'Активен' },
|
||||
preparing: { color: 'text-yellow-400', bg: 'bg-yellow-500/20', border: 'border-yellow-500/30', label: 'Подготовка' },
|
||||
finished: { color: 'text-gray-400', bg: 'bg-gray-500/20', border: 'border-gray-500/30', label: 'Завершён' },
|
||||
}
|
||||
const status = statusConfig[marathon.status as keyof typeof statusConfig] || statusConfig.finished
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Back button */}
|
||||
<Link to="/marathons" className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<Link
|
||||
to="/marathons"
|
||||
className="inline-flex items-center gap-2 text-gray-400 hover:text-neon-400 mb-6 transition-colors group"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
|
||||
К списку марафонов
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
{/* Main content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-3xl font-bold text-white">{marathon.title}</h1>
|
||||
<span className={`flex items-center gap-1 text-xs px-2 py-1 rounded ${
|
||||
{/* Hero Banner */}
|
||||
<div className="relative rounded-2xl overflow-hidden mb-8">
|
||||
{/* Background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-neon-500/10 via-dark-800 to-accent-500/10" />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(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 className="relative p-8">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start gap-6">
|
||||
{/* Title & Description */}
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-wrap items-center gap-3 mb-3">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white">{marathon.title}</h1>
|
||||
<span className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border ${
|
||||
marathon.is_public
|
||||
? 'bg-green-900/50 text-green-400'
|
||||
: 'bg-gray-700 text-gray-300'
|
||||
? 'bg-green-500/20 text-green-400 border-green-500/30'
|
||||
: 'bg-dark-700 text-gray-300 border-dark-600'
|
||||
}`}>
|
||||
{marathon.is_public ? (
|
||||
<><Globe className="w-3 h-3" /> Открытый</>
|
||||
) : (
|
||||
<><Lock className="w-3 h-3" /> Закрытый</>
|
||||
)}
|
||||
{marathon.is_public ? <Globe className="w-3 h-3" /> : <Lock className="w-3 h-3" />}
|
||||
{marathon.is_public ? 'Открытый' : 'Закрытый'}
|
||||
</span>
|
||||
<span className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border ${status.bg} ${status.color} ${status.border}`}>
|
||||
<span className={`w-2 h-2 rounded-full ${marathon.status === 'active' ? 'bg-neon-500 animate-pulse' : marathon.status === 'preparing' ? 'bg-yellow-500' : 'bg-gray-500'}`} />
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
{marathon.description && (
|
||||
<p className="text-gray-400">{marathon.description}</p>
|
||||
<p className="text-gray-400 max-w-2xl">{marathon.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap justify-end">
|
||||
{/* Кнопка присоединиться для открытых марафонов */}
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{marathon.is_public && !isParticipant && marathon.status !== 'finished' && (
|
||||
<Button onClick={handleJoinPublic} isLoading={isJoining}>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
<NeonButton onClick={handleJoinPublic} isLoading={isJoining} icon={<UserPlus className="w-4 h-4" />}>
|
||||
Присоединиться
|
||||
</Button>
|
||||
</NeonButton>
|
||||
)}
|
||||
|
||||
{/* Настройка для организаторов */}
|
||||
{marathon.status === 'preparing' && isOrganizer && (
|
||||
<Link to={`/marathons/${id}/lobby`}>
|
||||
<Button variant="secondary">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
<NeonButton variant="secondary" icon={<Settings className="w-4 h-4" />}>
|
||||
Настройка
|
||||
</Button>
|
||||
</NeonButton>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Предложить игру для участников (не организаторов) если разрешено */}
|
||||
{marathon.status === 'preparing' && isParticipant && !isOrganizer && marathon.game_proposal_mode === 'all_participants' && (
|
||||
<Link to={`/marathons/${id}/lobby`}>
|
||||
<Button variant="secondary">
|
||||
<Gamepad2 className="w-4 h-4 mr-2" />
|
||||
<NeonButton variant="secondary" icon={<Gamepad2 className="w-4 h-4" />}>
|
||||
Предложить игру
|
||||
</Button>
|
||||
</NeonButton>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{marathon.status === 'active' && isParticipant && (
|
||||
<Link to={`/marathons/${id}/play`}>
|
||||
<Button>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
<NeonButton icon={<Play className="w-4 h-4" />}>
|
||||
Играть
|
||||
</Button>
|
||||
</NeonButton>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link to={`/marathons/${id}/leaderboard`}>
|
||||
<Button variant="secondary">
|
||||
<Trophy className="w-4 h-4 mr-2" />
|
||||
<NeonButton variant="outline" icon={<Trophy className="w-4 h-4" />}>
|
||||
Рейтинг
|
||||
</Button>
|
||||
</NeonButton>
|
||||
</Link>
|
||||
|
||||
{marathon.status === 'active' && isOrganizer && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
<button
|
||||
onClick={handleFinish}
|
||||
isLoading={isFinishing}
|
||||
className="text-yellow-400 hover:text-yellow-300 hover:bg-yellow-900/20"
|
||||
disabled={isFinishing}
|
||||
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 && (
|
||||
<Button
|
||||
<NeonButton
|
||||
variant="ghost"
|
||||
onClick={handleDelete}
|
||||
isLoading={isDeleting}
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
className="!text-red-400 hover:!bg-red-500/10"
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
{/* Main content */}
|
||||
<div className="flex-1 min-w-0 space-y-6">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardContent className="text-center py-4">
|
||||
<div className="text-2xl font-bold text-white">{marathon.participants_count}</div>
|
||||
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
||||
<Users className="w-4 h-4" />
|
||||
Участников
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="text-center py-4">
|
||||
<div className="text-2xl font-bold text-white">{marathon.games_count}</div>
|
||||
<div className="text-sm text-gray-400">Игр</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="text-center py-4">
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{marathon.start_date ? format(new Date(marathon.start_date), 'd MMM') : '-'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
||||
<Calendar className="w-4 h-4" />
|
||||
Начало
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="text-center py-4">
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{marathon.end_date ? format(new Date(marathon.end_date), 'd MMM') : '-'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
||||
<CalendarCheck className="w-4 h-4" />
|
||||
Конец
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="text-center py-4">
|
||||
<div className={`text-2xl font-bold ${
|
||||
marathon.status === 'active' ? 'text-green-500' :
|
||||
marathon.status === 'preparing' ? 'text-yellow-500' : 'text-gray-400'
|
||||
}`}>
|
||||
{marathon.status === 'active' ? 'Активен' : marathon.status === 'preparing' ? 'Подготовка' : 'Завершён'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Статус</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<StatsCard
|
||||
label="Участников"
|
||||
value={marathon.participants_count}
|
||||
icon={<Users className="w-5 h-5" />}
|
||||
color="neon"
|
||||
/>
|
||||
<StatsCard
|
||||
label="Игр"
|
||||
value={marathon.games_count}
|
||||
icon={<Gamepad2 className="w-5 h-5" />}
|
||||
color="purple"
|
||||
/>
|
||||
<StatsCard
|
||||
label="Начало"
|
||||
value={marathon.start_date ? format(new Date(marathon.start_date), 'd MMM', { locale: ru }) : '-'}
|
||||
icon={<Calendar className="w-5 h-5" />}
|
||||
color="default"
|
||||
/>
|
||||
<StatsCard
|
||||
label="Конец"
|
||||
value={marathon.end_date ? format(new Date(marathon.end_date), 'd MMM', { locale: ru }) : '-'}
|
||||
icon={<CalendarCheck className="w-5 h-5" />}
|
||||
color="default"
|
||||
/>
|
||||
<StatsCard
|
||||
label="Статус"
|
||||
value={status.label}
|
||||
icon={<Target className="w-5 h-5" />}
|
||||
color={marathon.status === 'active' ? 'neon' : marathon.status === 'preparing' ? 'default' : 'default'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Active event banner */}
|
||||
{marathon.status === 'active' && activeEvent?.event && (
|
||||
<div className="mb-8">
|
||||
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event control for organizers */}
|
||||
{marathon.status === 'active' && isOrganizer && (
|
||||
<Card className="mb-8">
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-medium text-white flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-yellow-500" />
|
||||
Управление событиями
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
<GlassCard>
|
||||
<button
|
||||
onClick={() => setShowEventControl(!showEventControl)}
|
||||
className="w-full flex items-center justify-between"
|
||||
>
|
||||
{showEventControl ? 'Скрыть' : 'Показать'}
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center">
|
||||
<Zap className="w-5 h-5 text-yellow-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h3 className="font-semibold text-white">Управление событиями</h3>
|
||||
<p className="text-sm text-gray-400">Активируйте бонусы для участников</p>
|
||||
</div>
|
||||
</div>
|
||||
{showEventControl ? (
|
||||
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
{showEventControl && activeEvent && (
|
||||
<div className="mt-6 pt-6 border-t border-dark-600">
|
||||
<EventControl
|
||||
marathonId={marathon.id}
|
||||
activeEvent={activeEvent}
|
||||
challenges={challenges}
|
||||
onEventChange={refreshEvent}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Invite link */}
|
||||
{marathon.status !== 'finished' && (
|
||||
<Card className="mb-8">
|
||||
<CardContent>
|
||||
<h3 className="font-medium text-white mb-3">Ссылка для приглашения</h3>
|
||||
<GlassCard>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
|
||||
<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">
|
||||
<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()}
|
||||
</code>
|
||||
<Button variant="secondary" onClick={copyInviteLink}>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Скопировано!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
Копировать
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<NeonButton variant="secondary" onClick={copyInviteLink} icon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}>
|
||||
{copied ? 'Скопировано!' : 'Копировать'}
|
||||
</NeonButton>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Поделитесь этой ссылкой с друзьями, чтобы они могли присоединиться к марафону
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* My stats */}
|
||||
{marathon.my_participation && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<h3 className="font-medium text-white mb-4">Ваша статистика</h3>
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-primary-500">
|
||||
<GlassCard variant="neon">
|
||||
<h3 className="font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Star className="w-5 h-5 text-yellow-500" />
|
||||
Ваша статистика
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center p-4 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||
<div className="text-3xl font-bold text-neon-400">
|
||||
{marathon.my_participation.total_points}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Очков</div>
|
||||
<div className="text-sm text-gray-400 mt-1">Очков</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-yellow-500">
|
||||
<div className="text-center p-4 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||
<div className="text-3xl font-bold text-yellow-400 flex items-center justify-center gap-1">
|
||||
{marathon.my_participation.current_streak}
|
||||
{marathon.my_participation.current_streak > 0 && (
|
||||
<span className="text-lg">🔥</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Серия</div>
|
||||
<div className="text-sm text-gray-400 mt-1">Серия</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-400">
|
||||
<div className="text-center p-4 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||
<div className="text-3xl font-bold text-gray-400 flex items-center justify-center gap-1">
|
||||
{marathon.my_participation.drop_count}
|
||||
<TrendingDown className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Пропусков</div>
|
||||
<div className="text-sm text-gray-400 mt-1">Пропусков</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Activity Feed - right sidebar */}
|
||||
{isParticipant && (
|
||||
<div className="lg:w-96 flex-shrink-0">
|
||||
<div className="lg:sticky lg:top-4">
|
||||
<div className="lg:sticky lg:top-24">
|
||||
<ActivityFeed
|
||||
ref={activityFeedRef}
|
||||
marathonId={marathon.id}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { marathonsApi } from '@/api'
|
||||
import type { MarathonListItem } from '@/types'
|
||||
import { Button, Card, CardContent } from '@/components/ui'
|
||||
import { Plus, Users, Calendar, Loader2 } from 'lucide-react'
|
||||
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
|
||||
import { Plus, Users, Calendar, Loader2, Trophy, Gamepad2, ChevronRight, Hash, Sparkles } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
|
||||
export function MarathonsPage() {
|
||||
const [marathons, setMarathons] = useState<MarathonListItem[]>([])
|
||||
@@ -12,6 +13,7 @@ export function MarathonsPage() {
|
||||
const [joinCode, setJoinCode] = useState('')
|
||||
const [joinError, setJoinError] = useState<string | null>(null)
|
||||
const [isJoining, setIsJoining] = useState(false)
|
||||
const [showJoinSection, setShowJoinSection] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadMarathons()
|
||||
@@ -36,6 +38,7 @@ export function MarathonsPage() {
|
||||
try {
|
||||
await marathonsApi.join(joinCode.trim())
|
||||
setJoinCode('')
|
||||
setShowJoinSection(false)
|
||||
await loadMarathons()
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } }
|
||||
@@ -45,112 +48,217 @@ export function MarathonsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case 'preparing':
|
||||
return 'bg-yellow-500/20 text-yellow-500'
|
||||
return {
|
||||
color: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||
text: 'Подготовка',
|
||||
dot: 'bg-yellow-500',
|
||||
}
|
||||
case 'active':
|
||||
return 'bg-green-500/20 text-green-500'
|
||||
return {
|
||||
color: 'bg-neon-500/20 text-neon-400 border-neon-500/30',
|
||||
text: 'Активен',
|
||||
dot: 'bg-neon-500 animate-pulse',
|
||||
}
|
||||
case 'finished':
|
||||
return 'bg-gray-500/20 text-gray-400'
|
||||
return {
|
||||
color: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||
text: 'Завершён',
|
||||
dot: 'bg-gray-500',
|
||||
}
|
||||
default:
|
||||
return 'bg-gray-500/20 text-gray-400'
|
||||
return {
|
||||
color: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||
text: status,
|
||||
dot: 'bg-gray-500',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'preparing':
|
||||
return 'Подготовка'
|
||||
case 'active':
|
||||
return 'Активен'
|
||||
case 'finished':
|
||||
return 'Завершён'
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
// Stats
|
||||
const activeCount = marathons.filter(m => m.status === 'active').length
|
||||
const completedCount = marathons.filter(m => m.status === 'finished').length
|
||||
const totalParticipants = marathons.reduce((acc, m) => acc + m.participants_count, 0)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
|
||||
<p className="text-gray-400">Загрузка марафонов...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-white">Мои марафоны</h1>
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Мои марафоны</h1>
|
||||
<p className="text-gray-400">Управляйте своими игровыми соревнованиями</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<NeonButton
|
||||
variant="outline"
|
||||
onClick={() => setShowJoinSection(!showJoinSection)}
|
||||
icon={<Hash className="w-4 h-4" />}
|
||||
>
|
||||
По коду
|
||||
</NeonButton>
|
||||
<Link to="/marathons/create">
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Создать марафон
|
||||
</Button>
|
||||
<NeonButton icon={<Plus className="w-4 h-4" />}>
|
||||
Создать
|
||||
</NeonButton>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{marathons.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<StatsCard
|
||||
label="Всего"
|
||||
value={marathons.length}
|
||||
icon={<Gamepad2 className="w-6 h-6" />}
|
||||
color="default"
|
||||
/>
|
||||
<StatsCard
|
||||
label="Активных"
|
||||
value={activeCount}
|
||||
icon={<Sparkles className="w-6 h-6" />}
|
||||
color="neon"
|
||||
/>
|
||||
<StatsCard
|
||||
label="Завершено"
|
||||
value={completedCount}
|
||||
icon={<Trophy className="w-6 h-6" />}
|
||||
color="purple"
|
||||
/>
|
||||
<StatsCard
|
||||
label="Участников"
|
||||
value={totalParticipants}
|
||||
icon={<Users className="w-6 h-6" />}
|
||||
color="pink"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Join marathon */}
|
||||
<Card className="mb-8">
|
||||
<CardContent>
|
||||
<h3 className="font-medium text-white mb-3">Присоединиться к марафону</h3>
|
||||
{showJoinSection && (
|
||||
<GlassCard className="mb-8 animate-slide-in-down" variant="neon">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
|
||||
<Hash className="w-5 h-5 text-accent-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">Присоединиться к марафону</h3>
|
||||
<p className="text-sm text-gray-400">Введите код приглашения</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={joinCode}
|
||||
onChange={(e) => setJoinCode(e.target.value)}
|
||||
placeholder="Введите код приглашения"
|
||||
className="input flex-1"
|
||||
onChange={(e) => setJoinCode(e.target.value.toUpperCase())}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleJoin()}
|
||||
placeholder="XXXXXX"
|
||||
className="input flex-1 font-mono text-center tracking-widest uppercase"
|
||||
maxLength={10}
|
||||
/>
|
||||
<Button onClick={handleJoin} isLoading={isJoining}>
|
||||
<NeonButton
|
||||
onClick={handleJoin}
|
||||
isLoading={isJoining}
|
||||
color="purple"
|
||||
>
|
||||
Присоединиться
|
||||
</Button>
|
||||
</NeonButton>
|
||||
</div>
|
||||
{joinError && <p className="mt-2 text-sm text-red-500">{joinError}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{joinError && (
|
||||
<p className="mt-3 text-sm text-red-400 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-red-500 rounded-full" />
|
||||
{joinError}
|
||||
</p>
|
||||
)}
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Marathon list */}
|
||||
{marathons.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
<p className="text-gray-400 mb-4">У вас пока нет марафонов</p>
|
||||
<GlassCard className="text-center py-16">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-2xl bg-dark-700 flex items-center justify-center">
|
||||
<Gamepad2 className="w-10 h-10 text-gray-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-white mb-2">Нет марафонов</h3>
|
||||
<p className="text-gray-400 mb-6 max-w-sm mx-auto">
|
||||
Создайте свой первый марафон или присоединитесь к существующему по коду
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<NeonButton
|
||||
variant="outline"
|
||||
onClick={() => setShowJoinSection(true)}
|
||||
icon={<Hash className="w-4 h-4" />}
|
||||
>
|
||||
Ввести код
|
||||
</NeonButton>
|
||||
<Link to="/marathons/create">
|
||||
<Button>Создать первый марафон</Button>
|
||||
<NeonButton icon={<Plus className="w-4 h-4" />}>
|
||||
Создать марафон
|
||||
</NeonButton>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</GlassCard>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{marathons.map((marathon) => (
|
||||
{marathons.map((marathon, index) => {
|
||||
const status = getStatusConfig(marathon.status)
|
||||
return (
|
||||
<Link key={marathon.id} to={`/marathons/${marathon.id}`}>
|
||||
<Card className="hover:bg-gray-700/50 transition-colors cursor-pointer">
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div
|
||||
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>
|
||||
<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}
|
||||
</h3>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-400">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Users className="w-4 h-4" />
|
||||
{marathon.participants_count} участников
|
||||
{marathon.participants_count}
|
||||
</span>
|
||||
{marathon.start_date && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-sm ${getStatusColor(marathon.status)}`}>
|
||||
{getStatusText(marathon.status)}
|
||||
</div>
|
||||
|
||||
{/* Status & Arrow */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`px-3 py-1.5 rounded-full text-sm font-medium border flex items-center gap-2 ${status.color}`}>
|
||||
<span className={`w-2 h-2 rounded-full ${status.dot}`} />
|
||||
{status.text}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ChevronRight className="w-5 h-5 text-gray-600 group-hover:text-neon-400 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,33 +1,62 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Gamepad2, Home, Ghost } from 'lucide-react'
|
||||
import { NeonButton } from '@/components/ui'
|
||||
import { Gamepad2, Home, Ghost, Sparkles } from 'lucide-react'
|
||||
|
||||
export function NotFoundPage() {
|
||||
return (
|
||||
<div className="min-h-[60vh] flex flex-col items-center justify-center text-center px-4">
|
||||
{/* Иконка с анимацией */}
|
||||
<div className="relative mb-8">
|
||||
<Ghost className="w-32 h-32 text-gray-700 animate-bounce" />
|
||||
<Gamepad2 className="w-12 h-12 text-primary-500 absolute -bottom-2 -right-2" />
|
||||
<div className="min-h-[70vh] flex flex-col items-center justify-center text-center px-4">
|
||||
{/* Background effects */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-1/3 -left-32 w-96 h-96 bg-accent-500/5 rounded-full blur-[100px]" />
|
||||
<div className="absolute bottom-1/3 -right-32 w-96 h-96 bg-neon-500/5 rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
{/* Заголовок */}
|
||||
<h1 className="text-7xl font-bold text-white mb-4">404</h1>
|
||||
<h2 className="text-2xl font-semibold text-gray-400 mb-2">
|
||||
{/* Icon */}
|
||||
<div className="relative mb-8 animate-float">
|
||||
<div className="w-32 h-32 rounded-3xl bg-dark-700/50 border border-dark-600 flex items-center justify-center">
|
||||
<Ghost className="w-20 h-20 text-gray-600" />
|
||||
</div>
|
||||
<div className="absolute -bottom-2 -right-2 w-12 h-12 rounded-xl bg-neon-500/20 border border-neon-500/30 flex items-center justify-center">
|
||||
<Gamepad2 className="w-6 h-6 text-neon-400" />
|
||||
</div>
|
||||
{/* Glitch effect dots */}
|
||||
<div className="absolute -top-2 -left-2 w-3 h-3 rounded-full bg-accent-500/50 animate-pulse" />
|
||||
<div className="absolute top-4 -right-4 w-2 h-2 rounded-full bg-neon-500/50 animate-pulse" style={{ animationDelay: '0.5s' }} />
|
||||
</div>
|
||||
|
||||
{/* 404 text with glitch effect */}
|
||||
<div className="relative mb-4">
|
||||
<h1 className="text-8xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-neon-400 via-accent-400 to-pink-400">
|
||||
404
|
||||
</h1>
|
||||
<div className="absolute inset-0 text-8xl font-bold text-neon-500/20 blur-xl">
|
||||
404
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-white mb-3">
|
||||
Страница не найдена
|
||||
</h2>
|
||||
<p className="text-gray-500 mb-8 max-w-md">
|
||||
<p className="text-gray-400 mb-8 max-w-md">
|
||||
Похоже, эта страница ушла на марафон и не вернулась.
|
||||
Попробуй начать с главной.
|
||||
<br />
|
||||
<span className="text-gray-500">Попробуй начать с главной.</span>
|
||||
</p>
|
||||
|
||||
{/* Кнопка */}
|
||||
{/* Button */}
|
||||
<Link to="/">
|
||||
<Button size="lg" className="flex items-center gap-2">
|
||||
<Home className="w-5 h-5" />
|
||||
<NeonButton size="lg" icon={<Home className="w-5 h-5" />}>
|
||||
На главную
|
||||
</Button>
|
||||
</NeonButton>
|
||||
</Link>
|
||||
|
||||
{/* Decorative sparkles */}
|
||||
<div className="absolute top-1/4 left-1/4 opacity-20">
|
||||
<Sparkles className="w-6 h-6 text-neon-400 animate-pulse" />
|
||||
</div>
|
||||
<div className="absolute bottom-1/3 right-1/4 opacity-20">
|
||||
<Sparkles className="w-4 h-4 text-accent-400 animate-pulse" style={{ animationDelay: '1s' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,15 +7,15 @@ import { usersApi, telegramApi, authApi } from '@/api'
|
||||
import type { UserStats } from '@/types'
|
||||
import { useToast } from '@/store/toast'
|
||||
import {
|
||||
Button, Input, Card, CardHeader, CardTitle, CardContent, clearAvatarCache
|
||||
NeonButton, Input, GlassCard, StatsCard, clearAvatarCache
|
||||
} from '@/components/ui'
|
||||
import {
|
||||
User, Camera, Trophy, Target, CheckCircle, Flame,
|
||||
Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
|
||||
Eye, EyeOff, Save, KeyRound
|
||||
Eye, EyeOff, Save, KeyRound, Shield
|
||||
} from 'lucide-react'
|
||||
|
||||
// Схемы валидации
|
||||
// Schemas
|
||||
const nicknameSchema = z.object({
|
||||
nickname: z.string().min(2, 'Минимум 2 символа').max(50, 'Максимум 50 символов'),
|
||||
})
|
||||
@@ -33,10 +33,10 @@ type NicknameForm = z.infer<typeof nicknameSchema>
|
||||
type PasswordForm = z.infer<typeof passwordSchema>
|
||||
|
||||
export function ProfilePage() {
|
||||
const { user, updateUser } = useAuthStore()
|
||||
const { user, updateUser, avatarVersion, bumpAvatarVersion } = useAuthStore()
|
||||
const toast = useToast()
|
||||
|
||||
// Состояние
|
||||
// State
|
||||
const [stats, setStats] = useState<UserStats | null>(null)
|
||||
const [isLoadingStats, setIsLoadingStats] = useState(true)
|
||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
|
||||
@@ -53,7 +53,7 @@ export function ProfilePage() {
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Формы
|
||||
// Forms
|
||||
const nicknameForm = useForm<NicknameForm>({
|
||||
resolver: zodResolver(nicknameSchema),
|
||||
defaultValues: { nickname: user?.nickname || '' },
|
||||
@@ -64,7 +64,7 @@ export function ProfilePage() {
|
||||
defaultValues: { current_password: '', new_password: '', confirm_password: '' },
|
||||
})
|
||||
|
||||
// Загрузка статистики
|
||||
// Load stats
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
return () => {
|
||||
@@ -72,33 +72,59 @@ export function ProfilePage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Загрузка аватарки через API
|
||||
// Ref для отслеживания текущего blob URL
|
||||
const avatarBlobRef = useRef<string | null>(null)
|
||||
|
||||
// Load avatar via API
|
||||
useEffect(() => {
|
||||
if (user?.id && user?.avatar_url) {
|
||||
loadAvatar(user.id)
|
||||
} else {
|
||||
if (!user?.id || !user?.avatar_url) {
|
||||
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)
|
||||
try {
|
||||
const url = await usersApi.getAvatarUrl(userId)
|
||||
usersApi.getAvatarUrl(user.id, bustCache)
|
||||
.then(url => {
|
||||
if (cancelled) {
|
||||
URL.revokeObjectURL(url)
|
||||
return
|
||||
}
|
||||
// Очищаем старый blob URL
|
||||
if (avatarBlobRef.current) {
|
||||
URL.revokeObjectURL(avatarBlobRef.current)
|
||||
}
|
||||
avatarBlobRef.current = url
|
||||
setAvatarBlobUrl(url)
|
||||
} catch {
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setAvatarBlobUrl(null)
|
||||
} finally {
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
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(() => {
|
||||
if (user?.nickname) {
|
||||
nicknameForm.reset({ nickname: user.nickname })
|
||||
@@ -116,7 +142,7 @@ export function ProfilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Обновление никнейма
|
||||
// Update nickname
|
||||
const onNicknameSubmit = async (data: NicknameForm) => {
|
||||
try {
|
||||
const updatedUser = await usersApi.updateNickname(data)
|
||||
@@ -127,7 +153,7 @@ export function ProfilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка аватара
|
||||
// Upload avatar
|
||||
const handleAvatarClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
@@ -136,7 +162,6 @@ export function ProfilePage() {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// Валидация
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Файл должен быть изображением')
|
||||
return
|
||||
@@ -150,15 +175,11 @@ export function ProfilePage() {
|
||||
try {
|
||||
const updatedUser = await usersApi.uploadAvatar(file)
|
||||
updateUser({ avatar_url: updatedUser.avatar_url })
|
||||
// Перезагружаем аватарку через API
|
||||
if (user?.id) {
|
||||
// Очищаем старый blob URL и глобальный кэш
|
||||
if (avatarBlobUrl) {
|
||||
URL.revokeObjectURL(avatarBlobUrl)
|
||||
}
|
||||
clearAvatarCache(user.id)
|
||||
await loadAvatar(user.id)
|
||||
}
|
||||
// Bump version - это вызовет перезагрузку через useEffect
|
||||
bumpAvatarVersion()
|
||||
toast.success('Аватар обновлен')
|
||||
} catch {
|
||||
toast.error('Не удалось загрузить аватар')
|
||||
@@ -167,7 +188,7 @@ export function ProfilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Смена пароля
|
||||
// Change password
|
||||
const onPasswordSubmit = async (data: PasswordForm) => {
|
||||
try {
|
||||
await usersApi.changePassword({
|
||||
@@ -184,7 +205,7 @@ export function ProfilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram функции
|
||||
// Telegram functions
|
||||
const startPolling = () => {
|
||||
setIsPolling(true)
|
||||
let attempts = 0
|
||||
@@ -245,26 +266,28 @@ export function ProfilePage() {
|
||||
}
|
||||
|
||||
const isLinked = !!user?.telegram_id
|
||||
// Приоритет: загруженная аватарка (blob) > телеграм аватарка
|
||||
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
<h1 className="text-2xl font-bold text-white">Мой профиль</h1>
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Мой профиль</h1>
|
||||
<p className="text-gray-400">Настройки вашего аккаунта</p>
|
||||
</div>
|
||||
|
||||
{/* Карточка профиля */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-6">
|
||||
{/* Аватар */}
|
||||
{/* Profile Card */}
|
||||
<GlassCard variant="neon">
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6">
|
||||
{/* Avatar */}
|
||||
<div className="relative group flex-shrink-0">
|
||||
{isLoadingAvatar ? (
|
||||
<div className="w-24 h-24 rounded-full bg-gray-700 animate-pulse" />
|
||||
<div className="w-28 h-28 rounded-2xl bg-dark-700 skeleton" />
|
||||
) : (
|
||||
<button
|
||||
onClick={handleAvatarClick}
|
||||
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 ? (
|
||||
<img
|
||||
@@ -273,15 +296,15 @@ export function ProfilePage() {
|
||||
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" />
|
||||
</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 ? (
|
||||
<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>
|
||||
</button>
|
||||
@@ -295,90 +318,96 @@ export function ProfilePage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Форма никнейма */}
|
||||
<div className="flex-1">
|
||||
{/* Nickname Form */}
|
||||
<div className="flex-1 w-full sm:w-auto">
|
||||
<form onSubmit={nicknameForm.handleSubmit(onNicknameSubmit)} className="space-y-4">
|
||||
<Input
|
||||
label="Никнейм"
|
||||
{...nicknameForm.register('nickname')}
|
||||
error={nicknameForm.formState.errors.nickname?.message}
|
||||
/>
|
||||
<Button
|
||||
<NeonButton
|
||||
type="submit"
|
||||
size="sm"
|
||||
isLoading={nicknameForm.formState.isSubmitting}
|
||||
disabled={!nicknameForm.formState.isDirty}
|
||||
icon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Сохранить
|
||||
</Button>
|
||||
</NeonButton>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
|
||||
{/* Статистика */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{/* Stats */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Trophy className="w-5 h-5 text-yellow-500" />
|
||||
Статистика
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
</h2>
|
||||
{isLoadingStats ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="bg-gray-900 rounded-lg p-4 text-center">
|
||||
<div className="w-6 h-6 bg-gray-700 rounded mx-auto mb-2 animate-pulse" />
|
||||
<div className="h-8 w-12 bg-gray-700 rounded mx-auto mb-2 animate-pulse" />
|
||||
<div className="h-4 w-16 bg-gray-700 rounded mx-auto animate-pulse" />
|
||||
<div key={i} className="glass rounded-xl p-4">
|
||||
<div className="w-12 h-12 bg-dark-700 rounded-lg mb-3 skeleton" />
|
||||
<div className="h-8 w-16 bg-dark-700 rounded mb-2 skeleton" />
|
||||
<div className="h-4 w-20 bg-dark-700 rounded skeleton" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : stats ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-gray-900 rounded-lg p-4 text-center">
|
||||
<Target className="w-6 h-6 text-blue-400 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-white">{stats.marathons_count}</div>
|
||||
<div className="text-sm text-gray-400">Марафонов</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 rounded-lg p-4 text-center">
|
||||
<Trophy className="w-6 h-6 text-yellow-500 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-white">{stats.wins_count}</div>
|
||||
<div className="text-sm text-gray-400">Побед</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 rounded-lg p-4 text-center">
|
||||
<CheckCircle className="w-6 h-6 text-green-500 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-white">{stats.completed_assignments}</div>
|
||||
<div className="text-sm text-gray-400">Заданий</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 rounded-lg p-4 text-center">
|
||||
<Flame className="w-6 h-6 text-orange-500 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-white">{stats.total_points_earned}</div>
|
||||
<div className="text-sm text-gray-400">Очков</div>
|
||||
</div>
|
||||
<StatsCard
|
||||
label="Марафонов"
|
||||
value={stats.marathons_count}
|
||||
icon={<Target className="w-6 h-6" />}
|
||||
color="neon"
|
||||
/>
|
||||
<StatsCard
|
||||
label="Побед"
|
||||
value={stats.wins_count}
|
||||
icon={<Trophy className="w-6 h-6" />}
|
||||
color="purple"
|
||||
/>
|
||||
<StatsCard
|
||||
label="Заданий"
|
||||
value={stats.completed_assignments}
|
||||
icon={<CheckCircle className="w-6 h-6" />}
|
||||
color="neon"
|
||||
/>
|
||||
<StatsCard
|
||||
label="Очков"
|
||||
value={stats.total_points_earned}
|
||||
icon={<Flame className="w-6 h-6" />}
|
||||
color="pink"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-400 text-center">Не удалось загрузить статистику</p>
|
||||
<GlassCard className="text-center py-8">
|
||||
<p className="text-gray-400">Не удалось загрузить статистику</p>
|
||||
</GlassCard>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Telegram */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MessageCircle className="w-5 h-5 text-blue-400" />
|
||||
Telegram
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<GlassCard>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center">
|
||||
<MessageCircle className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Telegram</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
{isLinked ? 'Аккаунт привязан' : 'Привяжите для уведомлений'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLinked ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4 p-4 bg-gray-900 rounded-lg">
|
||||
<div className="w-12 h-12 rounded-full bg-blue-500/20 flex items-center justify-center overflow-hidden">
|
||||
<div className="flex items-center gap-4 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||
<div className="w-14 h-14 rounded-xl bg-blue-500/20 flex items-center justify-center overflow-hidden border border-blue-500/30">
|
||||
{user?.telegram_avatar_url ? (
|
||||
<img
|
||||
src={user.telegram_avatar_url}
|
||||
@@ -386,7 +415,7 @@ export function ProfilePage() {
|
||||
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 className="flex-1">
|
||||
@@ -397,53 +426,61 @@ export function ProfilePage() {
|
||||
<p className="text-blue-400 text-sm">@{user.telegram_username}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
<NeonButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={handleUnlinkTelegram}
|
||||
isLoading={telegramLoading}
|
||||
icon={<Link2Off className="w-4 h-4" />}
|
||||
>
|
||||
<Link2Off className="w-4 h-4 mr-2" />
|
||||
Отвязать
|
||||
</Button>
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-400">
|
||||
Привяжи Telegram для получения уведомлений о событиях и марафонах.
|
||||
Привяжите Telegram для получения уведомлений о событиях и марафонах.
|
||||
</p>
|
||||
{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">
|
||||
<Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
|
||||
<p className="text-blue-400">Ожидание привязки...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={handleLinkTelegram} isLoading={telegramLoading}>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
<NeonButton
|
||||
onClick={handleLinkTelegram}
|
||||
isLoading={telegramLoading}
|
||||
icon={<ExternalLink className="w-4 h-4" />}
|
||||
>
|
||||
Привязать Telegram
|
||||
</Button>
|
||||
</NeonButton>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
|
||||
{/* 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 ? (
|
||||
<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">
|
||||
<div className="relative">
|
||||
@@ -456,7 +493,7 @@ export function ProfilePage() {
|
||||
<button
|
||||
type="button"
|
||||
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" />}
|
||||
</button>
|
||||
@@ -472,7 +509,7 @@ export function ProfilePage() {
|
||||
<button
|
||||
type="button"
|
||||
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" />}
|
||||
</button>
|
||||
@@ -485,11 +522,15 @@ export function ProfilePage() {
|
||||
error={passwordForm.formState.errors.confirm_password?.message}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" isLoading={passwordForm.formState.isSubmitting}>
|
||||
<div className="flex gap-3">
|
||||
<NeonButton
|
||||
type="submit"
|
||||
isLoading={passwordForm.formState.isSubmitting}
|
||||
icon={<Save className="w-4 h-4" />}
|
||||
>
|
||||
Сменить пароль
|
||||
</Button>
|
||||
<Button
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
@@ -498,12 +539,11 @@ export function ProfilePage() {
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
</NeonButton>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { marathonsApi } from '@/api'
|
||||
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui'
|
||||
import { NeonButton, Input, GlassCard } from '@/components/ui'
|
||||
import { Gamepad2, UserPlus, AlertCircle, Trophy, Users, Zap, Target, Sparkles } from 'lucide-react'
|
||||
|
||||
const registerSchema = z.object({
|
||||
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 (
|
||||
<div className="max-w-md mx-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center">Регистрация</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="min-h-[80vh] flex items-center justify-center px-4 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 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">
|
||||
{(submitError || error) && (
|
||||
<div className="p-3 bg-red-500/20 border border-red-500 rounded-lg text-red-500 text-sm">
|
||||
{submitError || error}
|
||||
<div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||
<span>{submitError || error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -85,12 +175,13 @@ export function RegisterPage() {
|
||||
label="Логин"
|
||||
placeholder="Придумайте логин"
|
||||
error={errors.login?.message}
|
||||
autoComplete="username"
|
||||
{...register('login')}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Никнейм"
|
||||
placeholder="Придумайте никнейм"
|
||||
placeholder="Как вас называть?"
|
||||
error={errors.nickname?.message}
|
||||
{...register('nickname')}
|
||||
/>
|
||||
@@ -100,6 +191,7 @@ export function RegisterPage() {
|
||||
type="password"
|
||||
placeholder="Придумайте пароль"
|
||||
error={errors.password?.message}
|
||||
autoComplete="new-password"
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
@@ -108,22 +200,41 @@ export function RegisterPage() {
|
||||
type="password"
|
||||
placeholder="Повторите пароль"
|
||||
error={errors.confirmPassword?.message}
|
||||
autoComplete="new-password"
|
||||
{...register('confirmPassword')}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={isLoading}>
|
||||
<NeonButton
|
||||
type="submit"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
color="purple"
|
||||
isLoading={isLoading}
|
||||
icon={<UserPlus className="w-5 h-5" />}
|
||||
>
|
||||
Зарегистрироваться
|
||||
</Button>
|
||||
</NeonButton>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-gray-400 text-sm">
|
||||
{/* Footer */}
|
||||
<div className="mt-6 pt-6 border-t border-dark-600 text-center">
|
||||
<p className="text-gray-400 text-sm">
|
||||
Уже есть аккаунт?{' '}
|
||||
<Link to="/login" className="link">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-accent-400 hover:text-accent-300 transition-colors font-medium"
|
||||
>
|
||||
Войти
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
143
frontend/src/pages/ServerErrorPage.tsx
Normal file
143
frontend/src/pages/ServerErrorPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
241
frontend/src/pages/TeapotPage.tsx
Normal file
241
frontend/src/pages/TeapotPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -3,10 +3,10 @@ import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { usersApi } from '@/api'
|
||||
import type { UserProfilePublic } from '@/types'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui'
|
||||
import { GlassCard, StatsCard } from '@/components/ui'
|
||||
import {
|
||||
User, Trophy, Target, CheckCircle, Flame,
|
||||
Loader2, ArrowLeft, Calendar
|
||||
Loader2, ArrowLeft, Calendar, Zap
|
||||
} from 'lucide-react'
|
||||
|
||||
export function UserProfilePage() {
|
||||
@@ -82,8 +82,9 @@ export function UserProfilePage() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
|
||||
<p className="text-gray-400">Загрузка профиля...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -91,17 +92,17 @@ export function UserProfilePage() {
|
||||
if (error || !profile) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<User className="w-16 h-16 text-gray-600 mx-auto mb-4" />
|
||||
<GlassCard className="py-12 text-center">
|
||||
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-dark-700 flex items-center justify-center">
|
||||
<User className="w-10 h-10 text-gray-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-white mb-2">
|
||||
{error || 'Пользователь не найден'}
|
||||
</h2>
|
||||
<Link to="/" className="text-primary-400 hover:text-primary-300">
|
||||
<Link to="/" className="text-neon-400 hover:text-neon-300 transition-colors">
|
||||
Вернуться на главную
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -111,18 +112,18 @@ export function UserProfilePage() {
|
||||
{/* Кнопка назад */}
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
|
||||
className="flex items-center gap-2 text-gray-400 hover:text-neon-400 transition-colors group"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
|
||||
Назад
|
||||
</button>
|
||||
|
||||
{/* Профиль */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<GlassCard variant="neon">
|
||||
<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 ? (
|
||||
<img
|
||||
src={avatarBlobUrl}
|
||||
@@ -130,11 +131,16 @@ export function UserProfilePage() {
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-dark-700 to-dark-800">
|
||||
<User className="w-12 h-12 text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 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>
|
||||
@@ -142,55 +148,52 @@ export function UserProfilePage() {
|
||||
{profile.nickname}
|
||||
</h1>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
|
||||
{/* Статистика */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Trophy className="w-5 h-5 text-yellow-500" />
|
||||
Статистика
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<GlassCard>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center">
|
||||
<Trophy className="w-5 h-5 text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-white">Статистика</h2>
|
||||
<p className="text-sm text-gray-400">Достижения игрока</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-gray-900 rounded-lg p-4 text-center">
|
||||
<Target className="w-6 h-6 text-blue-400 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{profile.stats.marathons_count}
|
||||
<StatsCard
|
||||
label="Марафонов"
|
||||
value={profile.stats.marathons_count}
|
||||
icon={<Target className="w-6 h-6" />}
|
||||
color="neon"
|
||||
/>
|
||||
<StatsCard
|
||||
label="Побед"
|
||||
value={profile.stats.wins_count}
|
||||
icon={<Trophy className="w-6 h-6" />}
|
||||
color="purple"
|
||||
/>
|
||||
<StatsCard
|
||||
label="Заданий"
|
||||
value={profile.stats.completed_assignments}
|
||||
icon={<CheckCircle className="w-6 h-6" />}
|
||||
color="default"
|
||||
/>
|
||||
<StatsCard
|
||||
label="Очков"
|
||||
value={profile.stats.total_points_earned}
|
||||
icon={<Flame className="w-6 h-6" />}
|
||||
color="pink"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Марафонов</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 rounded-lg p-4 text-center">
|
||||
<Trophy className="w-6 h-6 text-yellow-500 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{profile.stats.wins_count}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Побед</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 rounded-lg p-4 text-center">
|
||||
<CheckCircle className="w-6 h-6 text-green-500 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{profile.stats.completed_assignments}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Заданий</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 rounded-lg p-4 text-center">
|
||||
<Flame className="w-6 h-6 text-orange-500 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{profile.stats.total_points_earned}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">Очков</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,3 +10,5 @@ export { LeaderboardPage } from './LeaderboardPage'
|
||||
export { ProfilePage } from './ProfilePage'
|
||||
export { UserProfilePage } from './UserProfilePage'
|
||||
export { NotFoundPage } from './NotFoundPage'
|
||||
export { TeapotPage } from './TeapotPage'
|
||||
export { ServerErrorPage } from './ServerErrorPage'
|
||||
|
||||
@@ -10,6 +10,7 @@ interface AuthState {
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
pendingInviteCode: string | null
|
||||
avatarVersion: number
|
||||
|
||||
login: (data: LoginData) => Promise<void>
|
||||
register: (data: RegisterData) => Promise<void>
|
||||
@@ -18,6 +19,7 @@ interface AuthState {
|
||||
setPendingInviteCode: (code: string | null) => void
|
||||
consumePendingInviteCode: () => string | null
|
||||
updateUser: (updates: Partial<User>) => void
|
||||
bumpAvatarVersion: () => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
@@ -29,6 +31,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
isLoading: false,
|
||||
error: null,
|
||||
pendingInviteCode: null,
|
||||
avatarVersion: 0,
|
||||
|
||||
login: async (data) => {
|
||||
set({ isLoading: true, error: null })
|
||||
@@ -97,6 +100,10 @@ export const useAuthStore = create<AuthState>()(
|
||||
set({ user: { ...currentUser, ...updates } })
|
||||
}
|
||||
},
|
||||
|
||||
bumpAvatarVersion: () => {
|
||||
set({ avatarVersion: get().avatarVersion + 1 })
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
|
||||
@@ -7,25 +7,91 @@ export default {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e',
|
||||
950: '#082f49',
|
||||
// Base dark colors - slightly warmer tones
|
||||
dark: {
|
||||
950: '#08090d',
|
||||
900: '#0d0e14',
|
||||
800: '#14161e',
|
||||
700: '#1c1e28',
|
||||
600: '#252732',
|
||||
500: '#2e313d',
|
||||
},
|
||||
// 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: {
|
||||
// Existing
|
||||
'spin-slow': 'spin 3s linear infinite',
|
||||
'wheel-spin': 'wheel-spin 4s cubic-bezier(0.17, 0.67, 0.12, 0.99) forwards',
|
||||
'fade-in': 'fade-in 0.3s ease-out',
|
||||
'slide-up': 'slide-up 0.3s ease-out',
|
||||
'fade-in': 'fade-in 0.3s ease-out forwards',
|
||||
'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: {
|
||||
'wheel-spin': {
|
||||
@@ -40,6 +106,119 @@ export default {
|
||||
'0%': { opacity: '0', transform: 'translateY(10px)' },
|
||||
'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
13
status-service/Dockerfile
Normal 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
109
status-service/main.py
Normal 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
227
status-service/monitors.py
Normal 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
|
||||
5
status-service/requirements.txt
Normal file
5
status-service/requirements.txt
Normal 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
|
||||
386
status-service/templates/index.html
Normal file
386
status-service/templates/index.html
Normal 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 %}
|
||||
• 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>
|
||||
Reference in New Issue
Block a user