Redesign p1

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

389
REDESIGN_PLAN.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import type { Game } from '@/types' import type { Game } from '@/types'
import { Gamepad2, Loader2, Sparkles } from 'lucide-react'
import { NeonButton } from './ui'
interface SpinWheelProps { interface SpinWheelProps {
games: Game[] games: Game[]
@@ -82,8 +84,11 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
if (games.length === 0) { if (games.length === 0) {
return ( return (
<div className="text-center py-12 text-gray-400"> <div className="glass rounded-2xl p-12 text-center">
Нет доступных игр для прокрутки <div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-700 flex items-center justify-center">
<Gamepad2 className="w-8 h-8 text-gray-600" />
</div>
<p className="text-gray-400">Нет доступных игр для прокрутки</p>
</div> </div>
) )
} }
@@ -91,25 +96,67 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
const containerHeight = VISIBLE_ITEMS * ITEM_HEIGHT const containerHeight = VISIBLE_ITEMS * ITEM_HEIGHT
const currentIndex = Math.round(offset / ITEM_HEIGHT) % games.length const currentIndex = Math.round(offset / ITEM_HEIGHT) % games.length
// Calculate opacity based on distance from center // Calculate opacity and scale based on distance from center
const getItemOpacity = (itemIndex: number) => { const getItemStyle = (itemIndex: number) => {
const itemPosition = itemIndex * ITEM_HEIGHT - offset const itemPosition = itemIndex * ITEM_HEIGHT - offset
const centerPosition = containerHeight / 2 - ITEM_HEIGHT / 2 const centerPosition = containerHeight / 2 - ITEM_HEIGHT / 2
const distanceFromCenter = Math.abs(itemPosition - centerPosition) const distanceFromCenter = Math.abs(itemPosition - centerPosition)
const maxDistance = containerHeight / 2 const maxDistance = containerHeight / 2
const opacity = Math.max(0, 1 - (distanceFromCenter / maxDistance) * 0.8) const normalizedDistance = distanceFromCenter / maxDistance
return opacity
const opacity = Math.max(0.15, 1 - normalizedDistance * 0.85)
const scale = Math.max(0.85, 1 - normalizedDistance * 0.15)
return { opacity, scale }
} }
return ( return (
<div className="flex flex-col items-center gap-6"> <div className="flex flex-col items-center gap-6">
{/* Wheel container */} {/* Wheel container */}
<div className="relative w-full max-w-md"> <div className="relative w-full max-w-lg">
{/* Selection indicator */} {/* Outer glow effect */}
<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 -inset-1 rounded-2xl transition-all duration-300 ${
<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" /> isSpinning
<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" /> ? 'bg-gradient-to-r from-neon-500 via-accent-500 to-neon-500 opacity-50 blur-xl animate-pulse'
: 'bg-gradient-to-r from-neon-500/20 to-accent-500/20 opacity-0'
}`} />
{/* Main container with glass effect */}
<div className="relative glass rounded-2xl border border-dark-600 overflow-hidden">
{/* Selection indicator - center highlight */}
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-[100px] z-20 pointer-events-none">
{/* Neon border glow */}
<div className={`absolute inset-0 border-2 rounded-lg transition-all duration-300 ${
isSpinning
? 'border-neon-400 shadow-[0_0_20px_rgba(0,240,255,0.5),inset_0_0_20px_rgba(0,240,255,0.1)]'
: 'border-neon-500/50 shadow-[0_0_10px_rgba(0,240,255,0.2)]'
}`} />
{/* Side arrows */}
<div className="absolute -left-1 top-1/2 -translate-y-1/2">
<div className={`w-3 h-6 transition-all duration-300 ${
isSpinning ? 'bg-neon-400' : 'bg-neon-500/70'
}`} style={{ clipPath: 'polygon(100% 0, 100% 100%, 0 50%)' }} />
</div> </div>
<div className="absolute -right-1 top-1/2 -translate-y-1/2">
<div className={`w-3 h-6 transition-all duration-300 ${
isSpinning ? 'bg-neon-400' : 'bg-neon-500/70'
}`} style={{ clipPath: 'polygon(0 0, 0 100%, 100% 50%)' }} />
</div>
{/* Inner glow */}
<div className={`absolute inset-0 rounded-lg transition-all duration-300 ${
isSpinning
? 'bg-neon-500/10'
: 'bg-neon-500/5'
}`} />
</div>
{/* Top fade gradient */}
<div className="absolute inset-x-0 top-0 h-24 bg-gradient-to-b from-dark-800 via-dark-800/80 to-transparent z-10 pointer-events-none" />
{/* Bottom fade gradient */}
<div className="absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-dark-800 via-dark-800/80 to-transparent z-10 pointer-events-none" />
{/* Items container */} {/* Items container */}
<div <div
@@ -118,7 +165,7 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
style={{ height: containerHeight }} style={{ height: containerHeight }}
> >
<div <div
className="absolute w-full transition-none" className="absolute w-full"
style={{ style={{
transform: `translateY(${containerHeight / 2 - ITEM_HEIGHT / 2 - offset}px)`, transform: `translateY(${containerHeight / 2 - ITEM_HEIGHT / 2 - offset}px)`,
}} }}
@@ -126,18 +173,35 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
{extendedGames.map((game, index) => { {extendedGames.map((game, index) => {
const realIndex = index % games.length const realIndex = index % games.length
const isSelected = !isSpinning && realIndex === currentIndex const isSelected = !isSpinning && realIndex === currentIndex
const opacity = getItemOpacity(index) const { opacity, scale } = getItemStyle(index)
return ( return (
<div <div
key={`${game.id}-${index}`} key={`${game.id}-${index}`}
className={`flex items-center gap-4 px-4 transition-transform duration-200 ${ className="px-4 transition-transform duration-200"
isSelected ? 'scale-105' : '' style={{
}`} height: ITEM_HEIGHT,
style={{ height: ITEM_HEIGHT, opacity }} opacity,
transform: `scale(${scale})`,
}}
> >
<div className={`
flex items-center gap-4 h-full px-4 rounded-xl
transition-all duration-300
${isSelected
? 'bg-neon-500/10 border border-neon-500/30'
: 'bg-transparent border border-transparent'
}
`}>
{/* Game cover */} {/* Game cover */}
<div className="w-16 h-16 rounded-lg overflow-hidden bg-gray-700 flex-shrink-0"> <div className={`
w-16 h-16 rounded-xl overflow-hidden flex-shrink-0
border transition-all duration-300
${isSelected
? 'border-neon-500/50 shadow-[0_0_15px_rgba(0,240,255,0.3)]'
: 'border-dark-600'
}
`}>
{game.cover_url ? ( {game.cover_url ? (
<img <img
src={game.cover_url} src={game.cover_url}
@@ -145,21 +209,32 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : ( ) : (
<div className="w-full h-full flex items-center justify-center text-2xl"> <div className="w-full h-full bg-gradient-to-br from-dark-700 to-dark-800 flex items-center justify-center">
🎮 <Gamepad2 className={`w-7 h-7 ${isSelected ? 'text-neon-400' : 'text-gray-600'}`} />
</div> </div>
)} )}
</div> </div>
{/* Game info */} {/* Game info */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-bold text-white truncate text-lg"> <h3 className={`
font-bold truncate text-lg transition-colors duration-300
${isSelected ? 'text-neon-400' : 'text-white'}
`}>
{game.title} {game.title}
</h3> </h3>
{game.genre && ( {game.genre && (
<p className="text-sm text-gray-400 truncate">{game.genre}</p> <p className="text-sm text-gray-400 truncate">{game.genre}</p>
)} )}
</div> </div>
{/* Selected indicator */}
{isSelected && (
<div className="flex-shrink-0">
<Sparkles className="w-5 h-5 text-neon-400 animate-pulse" />
</div>
)}
</div>
</div> </div>
) )
})} })}
@@ -167,43 +242,26 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
</div> </div>
</div> </div>
{/* Spinning indicator lines */}
{isSpinning && (
<>
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-full h-px bg-gradient-to-r from-transparent via-neon-500/50 to-transparent animate-pulse" />
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-full h-px bg-gradient-to-r from-transparent via-accent-500/50 to-transparent animate-pulse" style={{ transform: 'translateY(-50px)' }} />
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-full h-px bg-gradient-to-r from-transparent via-accent-500/50 to-transparent animate-pulse" style={{ transform: 'translateY(50px)' }} />
</>
)}
</div>
{/* Spin button */} {/* Spin button */}
<button <NeonButton
onClick={handleSpin} onClick={handleSpin}
disabled={isSpinning || disabled} disabled={isSpinning || disabled}
className={` size="lg"
relative px-12 py-4 text-xl font-bold rounded-full className="px-12 text-xl"
transition-all duration-300 transform icon={isSpinning ? <Loader2 className="w-6 h-6 animate-spin" /> : <Sparkles className="w-6 h-6" />}
${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'
}
`}
> >
{isSpinning ? ( {isSpinning ? 'Крутится...' : 'КРУТИТЬ!'}
<span className="flex items-center gap-2"> </NeonButton>
<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>
) : (
'КРУТИТЬ!'
)}
</button>
</div> </div>
) )
} }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,211 @@
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">
<div>
<p className="text-sm text-gray-400 mb-1">{label}</p>
<p className={clsx('text-2xl font-bold', 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-12 h-12 rounded-lg flex items-center justify-center',
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_30px_rgba(0,240,255,0.15)]',
},
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_30px_rgba(168,85,247,0.15)]',
},
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_30px_rgba(236,72,153,0.15)]',
},
}
const colors = colorClasses[color]
return (
<div
className={clsx(
'group glass rounded-xl p-6 border border-dark-600 transition-all duration-300',
'hover:-translate-y-1',
colors.border,
colors.glow,
className
)}
>
<div
className={clsx(
'w-14 h-14 rounded-xl flex items-center justify-center mb-4 transition-colors',
colors.icon
)}
>
{icon}
</div>
<h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
<p className="text-gray-400 text-sm">{description}</p>
</div>
)
}
// Interactive card with animated border
interface AnimatedBorderCardProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode
className?: string
}
export function AnimatedBorderCard({
children,
className,
...props
}: AnimatedBorderCardProps) {
return (
<div className={clsx('relative group', className)} {...props}>
{/* Animated gradient border */}
<div
className="absolute -inset-0.5 bg-gradient-to-r from-neon-500 via-accent-500 to-pink-500 rounded-xl opacity-30 group-hover:opacity-60 blur transition-opacity duration-300"
style={{
backgroundSize: '200% 200%',
animation: 'gradient-flow 3s linear infinite',
}}
/>
{/* Card content */}
<div className="relative glass-dark rounded-xl p-6">{children}</div>
</div>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,166 @@
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 20px rgba(0, 240, 255, 0.5)',
glowHover: '0 0 30px rgba(0, 240, 255, 0.7)',
},
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 20px rgba(168, 85, 247, 0.5)',
glowHover: '0 0 30px rgba(168, 85, 247, 0.7)',
},
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 20px rgba(236, 72, 153, 0.5)',
glowHover: '0 0 30px rgba(236, 72, 153, 0.7)',
},
}
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' ? colors.glow : undefined,
}}
onMouseEnter={(e) => {
if (glow && !disabled && variant !== 'ghost') {
e.currentTarget.style.boxShadow = colors.glowHover
}
props.onMouseEnter?.(e)
}}
onMouseLeave={(e) => {
if (glow && !disabled && variant !== 'ghost') {
e.currentTarget.style.boxShadow = 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_30px_rgba(168,85,247,0.5)]',
'disabled:opacity-50 disabled:cursor-not-allowed',
'focus:outline-none focus:ring-2 focus:ring-accent-500 focus:ring-offset-2 focus:ring-offset-dark-900',
sizeClasses[size],
className
)}
{...props}
>
{isLoading && <Loader2 className="w-5 h-5 animate-spin" />}
{!isLoading && icon && <span className="w-5 h-5">{icon}</span>}
{children}
</button>
)
}
)
GradientButton.displayName = 'GradientButton'

View File

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

View File

@@ -2,11 +2,118 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
body { /* ========================================
@apply bg-gray-900 text-gray-100 min-h-screen; CSS Variables
======================================== */
:root {
/* Base colors */
--color-dark-950: #050508;
--color-dark-900: #0a0a0f;
--color-dark-800: #12121a;
--color-dark-700: #1a1a24;
--color-dark-600: #1e1e2e;
--color-dark-500: #2a2a3a;
/* Neon cyan (primary) */
--color-neon-500: #00f0ff;
--color-neon-400: #22d3ee;
--color-neon-600: #00d4e4;
/* Purple accent */
--color-accent-500: #a855f7;
--color-accent-600: #9333ea;
--color-accent-700: #7c3aed;
/* Pink highlight */
--color-pink-500: #ec4899;
/* Glow colors */
--glow-neon: 0 0 5px #00f0ff, 0 0 10px #00f0ff, 0 0 20px #00f0ff;
--glow-neon-lg: 0 0 10px #00f0ff, 0 0 20px #00f0ff, 0 0 40px #00f0ff, 0 0 60px #00f0ff;
--glow-purple: 0 0 5px #a855f7, 0 0 10px #a855f7, 0 0 20px #a855f7;
--glow-pink: 0 0 5px #ec4899, 0 0 10px #ec4899, 0 0 20px #ec4899;
/* Text glow */
--text-glow-neon: 0 0 10px #00f0ff, 0 0 20px #00f0ff, 0 0 30px #00f0ff;
--text-glow-purple: 0 0 10px #a855f7, 0 0 20px #a855f7, 0 0 30px #a855f7;
} }
/* 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(0, 240, 255, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 240, 255, 0.02) 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");
}
/* ========================================
Selection Styles
======================================== */
::selection {
background: rgba(0, 240, 255, 0.3);
color: #fff;
}
::-moz-selection {
background: rgba(0, 240, 255, 0.3);
color: #fff;
}
/* ========================================
Custom Scrollbar (Neon Style)
======================================== */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-dark-800);
}
::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, var(--color-neon-500), var(--color-accent-500));
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, var(--color-neon-400), var(--color-accent-600));
}
::-webkit-scrollbar-corner {
background: var(--color-dark-800);
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-neon-500) var(--color-dark-800);
}
/* Custom scrollbar class for specific elements */
.custom-scrollbar::-webkit-scrollbar { .custom-scrollbar::-webkit-scrollbar {
width: 6px; width: 6px;
} }
@@ -16,46 +123,450 @@ body {
} }
.custom-scrollbar::-webkit-scrollbar-thumb { .custom-scrollbar::-webkit-scrollbar-thumb {
background: #4b5563; background: var(--color-dark-500);
border-radius: 3px; border-radius: 3px;
} }
.custom-scrollbar::-webkit-scrollbar-thumb:hover { .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #6b7280; background: var(--color-neon-500);
} }
/* Firefox */ /* ========================================
.custom-scrollbar { Glitch Effect
scrollbar-width: thin; ======================================== */
scrollbar-color: #4b5563 transparent; .glitch {
position: relative;
animation: glitch-skew 1s infinite linear alternate-reverse;
} }
.glitch::before,
.glitch::after {
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.glitch::before {
left: 2px;
text-shadow: -2px 0 #ff00ff;
clip: rect(44px, 450px, 56px, 0);
animation: glitch-anim 5s infinite linear alternate-reverse;
}
.glitch::after {
left: -2px;
text-shadow: -2px 0 #00ffff, 2px 2px #ff00ff;
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 5px #00f0ff, 0 0 10px #00f0ff, 0 0 20px #00f0ff;
}
50% {
box-shadow: 0 0 10px #00f0ff, 0 0 20px #00f0ff, 0 0 40px #00f0ff, 0 0 60px #00f0ff;
}
}
/* ========================================
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(18, 18, 26, 0.6);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 240, 255, 0.2);
box-shadow: inset 0 0 20px rgba(0, 240, 255, 0.05);
}
/* ========================================
Gradient Utilities
======================================== */
.gradient-neon {
background: linear-gradient(135deg, #00f0ff, #a855f7);
}
.gradient-neon-text {
background: linear-gradient(135deg, #00f0ff, #a855f7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.gradient-pink-purple {
background: linear-gradient(135deg, #ec4899, #a855f7);
}
.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, #00f0ff, #a855f7, #ec4899, #00f0ff);
background-size: 300% 300%;
border-radius: 14px;
z-index: -1;
animation: gradient-flow 3s linear infinite;
}
@keyframes gradient-flow {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* ========================================
Shimmer Effect
======================================== */
.shimmer {
position: relative;
overflow: hidden;
}
.shimmer::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.1),
transparent
);
animation: shimmer 2s infinite;
}
@keyframes shimmer {
100% { left: 100%; }
}
/* ========================================
Component Layer
======================================== */
@layer components { @layer components {
/* Buttons */
.btn { .btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed; @apply px-4 py-2 rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
} }
.btn-primary { .btn-primary {
@apply bg-primary-600 hover:bg-primary-700 text-white; @apply bg-neon-500 hover:bg-neon-400 text-dark-900 font-semibold;
box-shadow: 0 0 10px rgba(0, 240, 255, 0.3);
}
.btn-primary:hover {
box-shadow: 0 0 20px rgba(0, 240, 255, 0.5);
} }
.btn-secondary { .btn-secondary {
@apply bg-gray-700 hover:bg-gray-600 text-white; @apply bg-dark-600 hover:bg-dark-500 text-white border border-dark-500;
} }
.btn-danger { .btn-danger {
@apply bg-red-600 hover:bg-red-700 text-white; @apply bg-red-600 hover:bg-red-700 text-white;
} }
.btn-ghost {
@apply bg-transparent hover:bg-dark-700 text-gray-300 hover:text-white;
}
.btn-neon {
@apply relative bg-transparent border-2 border-neon-500 text-neon-500 font-semibold overflow-hidden;
transition: all 0.3s ease;
}
.btn-neon:hover {
@apply text-dark-900;
background: var(--color-neon-500);
box-shadow: 0 0 20px rgba(0, 240, 255, 0.5);
}
/* Inputs */
.input { .input {
@apply w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent; @apply w-full px-4 py-3 bg-dark-800 border border-dark-600 rounded-lg text-white placeholder-gray-500 transition-all duration-200;
} }
.input:focus {
@apply outline-none border-neon-500;
box-shadow: 0 0 0 3px rgba(0, 240, 255, 0.1), 0 0 10px rgba(0, 240, 255, 0.2);
}
/* Cards */
.card { .card {
@apply bg-gray-800 rounded-xl p-6 shadow-lg; @apply bg-dark-800 rounded-xl p-6 border border-dark-600;
} }
.card-glass {
@apply rounded-xl p-6;
background: rgba(18, 18, 26, 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(0, 240, 255, 0.1);
border-color: rgba(0, 240, 255, 0.3);
}
/* Links */
.link { .link {
@apply text-primary-400 hover:text-primary-300 transition-colors; @apply text-neon-500 hover:text-neon-400 transition-colors;
}
/* Badges */
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-neon {
@apply bg-neon-500/20 text-neon-400 border border-neon-500/30;
}
.badge-purple {
@apply bg-accent-500/20 text-accent-400 border border-accent-500/30;
}
.badge-pink {
@apply bg-pink-500/20 text-pink-400 border border-pink-500/30;
}
/* Dividers */
.divider {
@apply border-t border-dark-600;
}
.divider-glow {
@apply border-t border-neon-500/30;
box-shadow: 0 0 10px rgba(0, 240, 255, 0.2);
}
}
/* ========================================
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 20px rgba(0, 240, 255, 0.3);
}
.hover-border-glow {
@apply transition-all duration-300;
}
.hover-border-glow:hover {
border-color: rgba(0, 240, 255, 0.5);
box-shadow: 0 0 15px rgba(0, 240, 255, 0.2);
}
/* Stagger children animations */
.stagger-children > * {
@apply animate-slide-in-up;
animation-fill-mode: both;
}
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
.stagger-children > *:nth-child(2) { animation-delay: 50ms; }
.stagger-children > *:nth-child(3) { animation-delay: 100ms; }
.stagger-children > *:nth-child(4) { animation-delay: 150ms; }
.stagger-children > *:nth-child(5) { animation-delay: 200ms; }
.stagger-children > *:nth-child(6) { animation-delay: 250ms; }
.stagger-children > *:nth-child(7) { animation-delay: 300ms; }
.stagger-children > *:nth-child(8) { animation-delay: 350ms; }
/* ========================================
Skeleton Loading
======================================== */
.skeleton {
@apply relative overflow-hidden bg-dark-700 rounded;
}
.skeleton::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.05),
transparent
);
animation: skeleton-pulse 1.5s infinite;
}
@keyframes skeleton-pulse {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
/* ========================================
Focus States (Accessibility)
======================================== */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-neon-500 focus:ring-offset-2 focus:ring-offset-dark-900;
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -7,25 +7,91 @@ export default {
theme: { theme: {
extend: { extend: {
colors: { colors: {
primary: { // Base dark colors
50: '#f0f9ff', dark: {
100: '#e0f2fe', 950: '#050508',
200: '#bae6fd', 900: '#0a0a0f',
300: '#7dd3fc', 800: '#12121a',
400: '#38bdf8', 700: '#1a1a24',
500: '#0ea5e9', 600: '#1e1e2e',
600: '#0284c7', 500: '#2a2a3a',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
950: '#082f49',
}, },
// Neon cyan (primary)
neon: {
50: '#ecfeff',
100: '#cffafe',
200: '#a5f3fc',
300: '#67e8f9',
400: '#22d3ee',
500: '#00f0ff',
600: '#00d4e4',
700: '#00a8b8',
800: '#007c8a',
900: '#005a64',
},
// Purple accent
accent: {
50: '#faf5ff',
100: '#f3e8ff',
200: '#e9d5ff',
300: '#d8b4fe',
400: '#c084fc',
500: '#a855f7',
600: '#9333ea',
700: '#7c3aed',
800: '#6b21a8',
900: '#581c87',
},
// Pink highlight
pink: {
400: '#f472b6',
500: '#ec4899',
600: '#db2777',
},
// Keep primary for backwards compatibility
primary: {
50: '#ecfeff',
100: '#cffafe',
200: '#a5f3fc',
300: '#67e8f9',
400: '#22d3ee',
500: '#00f0ff',
600: '#00d4e4',
700: '#00a8b8',
800: '#007c8a',
900: '#005a64',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
display: ['Orbitron', 'sans-serif'],
}, },
animation: { animation: {
// Existing
'spin-slow': 'spin 3s linear infinite', 'spin-slow': 'spin 3s linear infinite',
'wheel-spin': 'wheel-spin 4s cubic-bezier(0.17, 0.67, 0.12, 0.99) forwards', 'wheel-spin': 'wheel-spin 4s cubic-bezier(0.17, 0.67, 0.12, 0.99) forwards',
'fade-in': 'fade-in 0.3s ease-out', 'fade-in': 'fade-in 0.3s ease-out forwards',
'slide-up': 'slide-up 0.3s ease-out', 'slide-up': 'slide-up 0.3s ease-out forwards',
// New animations
'glitch': 'glitch 1s linear infinite',
'glitch-1': 'glitch-1 0.5s infinite linear alternate-reverse',
'glitch-2': 'glitch-2 0.5s infinite linear alternate-reverse',
'glow-pulse': 'glow-pulse 2s ease-in-out infinite',
'float': 'float 6s ease-in-out infinite',
'shimmer': 'shimmer 2s linear infinite',
'slide-in-right': 'slide-in-right 0.3s ease-out forwards',
'slide-in-left': 'slide-in-left 0.3s ease-out forwards',
'slide-in-up': 'slide-in-up 0.4s ease-out forwards',
'slide-in-down': 'slide-in-down 0.3s ease-out forwards',
'scale-in': 'scale-in 0.2s ease-out forwards',
'bounce-in': 'bounce-in 0.5s ease-out forwards',
'pulse-neon': 'pulse-neon 2s ease-in-out infinite',
'border-flow': 'border-flow 3s linear infinite',
'typing': 'typing 3.5s steps(40, end), blink-caret 0.75s step-end infinite',
'counter': 'counter 2s ease-out forwards',
'shake': 'shake 0.5s ease-in-out',
'confetti': 'confetti 1s ease-out forwards',
}, },
keyframes: { keyframes: {
'wheel-spin': { 'wheel-spin': {
@@ -40,6 +106,119 @@ export default {
'0%': { opacity: '0', transform: 'translateY(10px)' }, '0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' }, '100%': { opacity: '1', transform: 'translateY(0)' },
}, },
'glitch': {
'0%, 100%': { transform: 'translate(0)' },
'20%': { transform: 'translate(-2px, 2px)' },
'40%': { transform: 'translate(-2px, -2px)' },
'60%': { transform: 'translate(2px, 2px)' },
'80%': { transform: 'translate(2px, -2px)' },
},
'glitch-1': {
'0%': { clipPath: 'inset(20% 0 60% 0)' },
'100%': { clipPath: 'inset(50% 0 30% 0)' },
},
'glitch-2': {
'0%': { clipPath: 'inset(60% 0 20% 0)' },
'100%': { clipPath: 'inset(30% 0 50% 0)' },
},
'glow-pulse': {
'0%, 100%': {
boxShadow: '0 0 5px var(--glow-color, #00f0ff), 0 0 10px var(--glow-color, #00f0ff), 0 0 20px var(--glow-color, #00f0ff)'
},
'50%': {
boxShadow: '0 0 10px var(--glow-color, #00f0ff), 0 0 20px var(--glow-color, #00f0ff), 0 0 40px var(--glow-color, #00f0ff)'
},
},
'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 5px var(--neon-color, #00f0ff), 0 0 10px var(--neon-color, #00f0ff)'
},
'50%': {
textShadow: '0 0 10px var(--neon-color, #00f0ff), 0 0 20px var(--neon-color, #00f0ff), 0 0 30px var(--neon-color, #00f0ff)'
},
},
'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: '#00f0ff' },
},
'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, #00f0ff, #a855f7, #00f0ff)',
'cyber-grid': `
linear-gradient(rgba(0, 240, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 240, 255, 0.03) 1px, transparent 1px)
`,
},
backgroundSize: {
'grid': '50px 50px',
},
boxShadow: {
'neon': '0 0 5px #00f0ff, 0 0 10px #00f0ff, 0 0 20px #00f0ff',
'neon-lg': '0 0 10px #00f0ff, 0 0 20px #00f0ff, 0 0 40px #00f0ff, 0 0 60px #00f0ff',
'neon-purple': '0 0 5px #a855f7, 0 0 10px #a855f7, 0 0 20px #a855f7',
'neon-pink': '0 0 5px #ec4899, 0 0 10px #ec4899, 0 0 20px #ec4899',
'inner-glow': 'inset 0 0 20px rgba(0, 240, 255, 0.1)',
'glass': '0 8px 32px 0 rgba(0, 0, 0, 0.37)',
},
backdropBlur: {
'xs': '2px',
},
transitionDuration: {
'400': '400ms',
}, },
}, },
}, },