16 Commits

Author SHA1 Message Date
481bdabaa8 Add admin panel 2025-12-19 02:07:25 +07:00
8e634994bd Add challenges promotion 2025-12-18 23:47:11 +07:00
33f49f4e47 Fix security 2025-12-18 17:15:21 +07:00
57bad3b4a8 Redesign health service + create backup service 2025-12-18 03:35:13 +07:00
e43e579329 Finish 2025-12-17 22:13:39 +07:00
967176fab8 service 2025-12-17 22:10:01 +07:00
f371178518 500 2025-12-17 21:50:10 +07:00
3920a9bf8c teapot 2025-12-17 21:38:43 +07:00
790b2d6083 ПОЧТИ ГОТОВО 2025-12-17 20:59:47 +07:00
675a0fea0c PIZDEC 2025-12-17 20:29:22 +07:00
0b3837b08e Zaebalsya 2025-12-17 20:19:26 +07:00
7e7cdbcd76 Fix 2025-12-17 19:50:55 +07:00
debdd66458 Fix UI 2025-12-17 18:27:09 +07:00
332491454d Redesign p1 2025-12-17 02:03:33 +07:00
11f7b59471 Fix telegram avatar 2025-12-17 01:06:03 +07:00
1c07d8c5ff Fix avatars upload 2025-12-17 00:04:14 +07:00
107 changed files with 13334 additions and 2980 deletions

View File

@@ -10,6 +10,7 @@ OPENAI_API_KEY=sk-...
# Telegram Bot # Telegram Bot
TELEGRAM_BOT_TOKEN=123456:ABC-DEF... TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
BOT_API_SECRET=change_me_random_secret_for_bot_api
# S3 Storage - FirstVDS (set S3_ENABLED=true to use) # S3 Storage - FirstVDS (set S3_ENABLED=true to use)
S3_ENABLED=false S3_ENABLED=false
@@ -20,5 +21,14 @@ S3_SECRET_ACCESS_KEY=your-secret-access-key
S3_ENDPOINT_URL=https://s3.firstvds.ru S3_ENDPOINT_URL=https://s3.firstvds.ru
S3_PUBLIC_URL=https://your-bucket-name.s3.firstvds.ru S3_PUBLIC_URL=https://your-bucket-name.s3.firstvds.ru
# Backup Service
TELEGRAM_ADMIN_ID=947392854
S3_BACKUP_PREFIX=backups/
BACKUP_RETENTION_DAYS=14
# Status Service (optional - for external monitoring)
EXTERNAL_URL=https://your-domain.com
PUBLIC_URL=https://your-domain.com
# Frontend (for build) # Frontend (for build)
VITE_API_URL=/api/v1 VITE_API_URL=/api/v1

View File

@@ -31,6 +31,12 @@ help:
@echo " make shell - Open backend shell" @echo " make shell - Open backend shell"
@echo " make frontend-sh - Open frontend shell" @echo " make frontend-sh - Open frontend shell"
@echo "" @echo ""
@echo " Backup:"
@echo " make backup-now - Run backup immediately"
@echo " make backup-list - List available backups in S3"
@echo " make backup-restore - Restore from backup (interactive)"
@echo " make backup-logs - Show backup service logs"
@echo ""
@echo " Cleanup:" @echo " Cleanup:"
@echo " make clean - Stop and remove containers, volumes" @echo " make clean - Stop and remove containers, volumes"
@echo " make prune - Remove unused Docker resources" @echo " make prune - Remove unused Docker resources"
@@ -137,3 +143,20 @@ test-backend:
# Production # Production
prod: prod:
$(DC) -f docker-compose.yml up -d --build $(DC) -f docker-compose.yml up -d --build
# Backup
backup-now:
$(DC) exec backup python /app/backup.py
backup-list:
$(DC) exec backup python /app/restore.py
backup-restore:
@read -p "Backup filename: " file; \
$(DC) exec -it backup python /app/restore.py "$$file"
backup-logs:
$(DC) logs -f backup
backup-shell:
$(DC) exec backup bash

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

@@ -0,0 +1,28 @@
"""Add challenge proposals support
Revision ID: 011_add_challenge_proposals
Revises: 010_add_telegram_profile
Create Date: 2024-12-18
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '011_add_challenge_proposals'
down_revision: Union[str, None] = '010_add_telegram_profile'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('challenges', sa.Column('proposed_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True))
op.add_column('challenges', sa.Column('status', sa.String(20), server_default='approved', nullable=False))
def downgrade() -> None:
op.drop_column('challenges', 'status')
op.drop_column('challenges', 'proposed_by_id')

View File

@@ -0,0 +1,32 @@
"""Add user banned fields
Revision ID: 012_add_user_banned
Revises: 011_add_challenge_proposals
Create Date: 2024-12-18
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '012_add_user_banned'
down_revision: Union[str, None] = '011_add_challenge_proposals'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('users', sa.Column('is_banned', sa.Boolean(), server_default='false', nullable=False))
op.add_column('users', sa.Column('banned_at', sa.DateTime(), nullable=True))
op.add_column('users', sa.Column('banned_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True))
op.add_column('users', sa.Column('ban_reason', sa.String(500), nullable=True))
def downgrade() -> None:
op.drop_column('users', 'ban_reason')
op.drop_column('users', 'banned_by_id')
op.drop_column('users', 'banned_at')
op.drop_column('users', 'is_banned')

View File

@@ -0,0 +1,61 @@
"""Add admin_logs table
Revision ID: 013_add_admin_logs
Revises: 012_add_user_banned
Create Date: 2024-12-18
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '013_add_admin_logs'
down_revision: Union[str, None] = '012_add_user_banned'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def index_exists(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
indexes = inspector.get_indexes(table_name)
return any(idx['name'] == index_name for idx in indexes)
def upgrade() -> None:
if not table_exists('admin_logs'):
op.create_table(
'admin_logs',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('admin_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('action', sa.String(50), nullable=False),
sa.Column('target_type', sa.String(50), nullable=False),
sa.Column('target_id', sa.Integer(), nullable=False),
sa.Column('details', sa.JSON(), nullable=True),
sa.Column('ip_address', sa.String(50), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
if not index_exists('admin_logs', 'ix_admin_logs_admin_id'):
op.create_index('ix_admin_logs_admin_id', 'admin_logs', ['admin_id'])
if not index_exists('admin_logs', 'ix_admin_logs_action'):
op.create_index('ix_admin_logs_action', 'admin_logs', ['action'])
if not index_exists('admin_logs', 'ix_admin_logs_created_at'):
op.create_index('ix_admin_logs_created_at', 'admin_logs', ['created_at'])
def downgrade() -> None:
op.drop_index('ix_admin_logs_created_at', 'admin_logs')
op.drop_index('ix_admin_logs_action', 'admin_logs')
op.drop_index('ix_admin_logs_admin_id', 'admin_logs')
op.drop_table('admin_logs')

View File

@@ -0,0 +1,57 @@
"""Add admin_2fa_sessions table
Revision ID: 014_add_admin_2fa
Revises: 013_add_admin_logs
Create Date: 2024-12-18
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '014_add_admin_2fa'
down_revision: Union[str, None] = '013_add_admin_logs'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def index_exists(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
indexes = inspector.get_indexes(table_name)
return any(idx['name'] == index_name for idx in indexes)
def upgrade() -> None:
if not table_exists('admin_2fa_sessions'):
op.create_table(
'admin_2fa_sessions',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('code', sa.String(6), nullable=False),
sa.Column('telegram_sent', sa.Boolean(), server_default='false', nullable=False),
sa.Column('is_verified', sa.Boolean(), server_default='false', nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
if not index_exists('admin_2fa_sessions', 'ix_admin_2fa_sessions_user_id'):
op.create_index('ix_admin_2fa_sessions_user_id', 'admin_2fa_sessions', ['user_id'])
if not index_exists('admin_2fa_sessions', 'ix_admin_2fa_sessions_expires_at'):
op.create_index('ix_admin_2fa_sessions_expires_at', 'admin_2fa_sessions', ['expires_at'])
def downgrade() -> None:
op.drop_index('ix_admin_2fa_sessions_expires_at', 'admin_2fa_sessions')
op.drop_index('ix_admin_2fa_sessions_user_id', 'admin_2fa_sessions')
op.drop_table('admin_2fa_sessions')

View File

@@ -0,0 +1,54 @@
"""Add static_content table
Revision ID: 015_add_static_content
Revises: 014_add_admin_2fa
Create Date: 2024-12-18
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '015_add_static_content'
down_revision: Union[str, None] = '014_add_admin_2fa'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def index_exists(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
indexes = inspector.get_indexes(table_name)
return any(idx['name'] == index_name for idx in indexes)
def upgrade() -> None:
if not table_exists('static_content'):
op.create_table(
'static_content',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('key', sa.String(100), unique=True, nullable=False),
sa.Column('title', sa.String(200), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('updated_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
)
if not index_exists('static_content', 'ix_static_content_key'):
op.create_index('ix_static_content_key', 'static_content', ['key'], unique=True)
def downgrade() -> None:
op.drop_index('ix_static_content_key', 'static_content')
op.drop_table('static_content')

View File

@@ -0,0 +1,36 @@
"""Add banned_until field
Revision ID: 016_add_banned_until
Revises: 015_add_static_content
Create Date: 2024-12-19
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '016_add_banned_until'
down_revision: Union[str, None] = '015_add_static_content'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
if not column_exists('users', 'banned_until'):
op.add_column('users', sa.Column('banned_until', sa.DateTime(), nullable=True))
def downgrade() -> None:
if column_exists('users', 'banned_until'):
op.drop_column('users', 'banned_until')

View File

@@ -0,0 +1,32 @@
"""Make admin_id nullable in admin_logs for system actions
Revision ID: 017_admin_logs_nullable_admin_id
Revises: 016_add_banned_until
Create Date: 2024-12-19
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '017_admin_logs_nullable_admin_id'
down_revision: Union[str, None] = '016_add_banned_until'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Make admin_id nullable for system actions (like auto-unban)
op.alter_column('admin_logs', 'admin_id',
existing_type=sa.Integer(),
nullable=True)
def downgrade() -> None:
# Revert to not nullable (will fail if there are NULL values)
op.alter_column('admin_logs', 'admin_id',
existing_type=sa.Integer(),
nullable=False)

View File

@@ -1,13 +1,15 @@
from typing import Annotated from typing import Annotated
from datetime import datetime
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status, Header
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.core.security import decode_access_token from app.core.security import decode_access_token
from app.models import User, Participant, Marathon, UserRole, ParticipantRole from app.models import User, Participant, Marathon, UserRole, ParticipantRole, AdminLog, AdminActionType
security = HTTPBearer() security = HTTPBearer()
@@ -42,6 +44,50 @@ async def get_current_user(
detail="User not found", detail="User not found",
) )
# Check if user is banned
if user.is_banned:
# Auto-unban if ban expired
if user.banned_until and datetime.utcnow() > user.banned_until:
# Save ban info for logging before clearing
old_ban_reason = user.ban_reason
old_banned_until = user.banned_until.isoformat() if user.banned_until else None
user.is_banned = False
user.banned_at = None
user.banned_until = None
user.banned_by_id = None
user.ban_reason = None
# Log system auto-unban action
log = AdminLog(
admin_id=None, # System action, no admin
action=AdminActionType.USER_AUTO_UNBAN.value,
target_type="user",
target_id=user.id,
details={
"nickname": user.nickname,
"reason": old_ban_reason,
"banned_until": old_banned_until,
"system": True,
},
ip_address=None,
)
db.add(log)
await db.commit()
await db.refresh(user)
else:
# Still banned - return ban info in error
ban_info = {
"banned_at": user.banned_at.isoformat() if user.banned_at else None,
"banned_until": user.banned_until.isoformat() if user.banned_until else None,
"reason": user.ban_reason,
}
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ban_info,
)
return user return user
@@ -55,6 +101,21 @@ def require_admin(user: User) -> User:
return user return user
def require_admin_with_2fa(user: User) -> User:
"""Check if user is admin with Telegram linked (2FA enabled)"""
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
if not user.telegram_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Для доступа к админ-панели необходимо привязать Telegram в профиле",
)
return user
async def get_participant( async def get_participant(
db: AsyncSession, db: AsyncSession,
user_id: int, user_id: int,
@@ -145,3 +206,21 @@ async def require_creator(
# Type aliases for cleaner dependency injection # Type aliases for cleaner dependency injection
CurrentUser = Annotated[User, Depends(get_current_user)] CurrentUser = Annotated[User, Depends(get_current_user)]
DbSession = Annotated[AsyncSession, Depends(get_db)] DbSession = Annotated[AsyncSession, Depends(get_db)]
async def verify_bot_secret(
x_bot_secret: str | None = Header(None, alias="X-Bot-Secret")
) -> None:
"""Verify that request comes from trusted bot using secret key."""
if not settings.BOT_API_SECRET:
# If secret is not configured, skip check (for development)
return
if x_bot_secret != settings.BOT_API_SECRET:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid or missing bot secret"
)
BotSecretDep = Annotated[None, Depends(verify_bot_secret)]

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content
router = APIRouter(prefix="/api/v1") router = APIRouter(prefix="/api/v1")
@@ -15,3 +15,4 @@ router.include_router(admin.router)
router.include_router(events.router) router.include_router(events.router)
router.include_router(assignments.router) router.include_router(assignments.router)
router.include_router(telegram.router) router.include_router(telegram.router)
router.include_router(content.router)

View File

@@ -1,11 +1,19 @@
from fastapi import APIRouter, HTTPException, Query from datetime import datetime
from fastapi import APIRouter, HTTPException, Query, Request
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.api.deps import DbSession, CurrentUser, require_admin from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
from app.models import User, UserRole, Marathon, Participant, Game from app.models import User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent
from app.schemas import UserPublic, MarathonListItem, MessageResponse from app.schemas import (
UserPublic, MessageResponse,
AdminUserResponse, BanUserRequest, AdminLogResponse, AdminLogsListResponse,
BroadcastRequest, BroadcastResponse, StaticContentResponse, StaticContentUpdate,
StaticContentCreate, DashboardStats
)
from app.services.telegram_notifier import telegram_notifier
from app.core.rate_limit import limiter
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -14,21 +22,6 @@ class SetUserRole(BaseModel):
role: str = Field(..., pattern="^(user|admin)$") role: str = Field(..., pattern="^(user|admin)$")
class AdminUserResponse(BaseModel):
id: int
login: str
nickname: str
role: str
avatar_url: str | None = None
telegram_id: int | None = None
telegram_username: str | None = None
marathons_count: int = 0
created_at: str
class Config:
from_attributes = True
class AdminMarathonResponse(BaseModel): class AdminMarathonResponse(BaseModel):
id: int id: int
title: str title: str
@@ -44,6 +37,29 @@ class AdminMarathonResponse(BaseModel):
from_attributes = True from_attributes = True
# ============ Helper Functions ============
async def log_admin_action(
db,
admin_id: int,
action: str,
target_type: str,
target_id: int,
details: dict | None = None,
ip_address: str | None = None
):
"""Log an admin action."""
log = AdminLog(
admin_id=admin_id,
action=action,
target_type=target_type,
target_id=target_id,
details=details,
ip_address=ip_address,
)
db.add(log)
await db.commit()
@router.get("/users", response_model=list[AdminUserResponse]) @router.get("/users", response_model=list[AdminUserResponse])
async def list_users( async def list_users(
current_user: CurrentUser, current_user: CurrentUser,
@@ -51,9 +67,10 @@ async def list_users(
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100), limit: int = Query(50, ge=1, le=100),
search: str | None = None, search: str | None = None,
banned_only: bool = False,
): ):
"""List all users. Admin only.""" """List all users. Admin only."""
require_admin(current_user) require_admin_with_2fa(current_user)
query = select(User).order_by(User.created_at.desc()) query = select(User).order_by(User.created_at.desc())
@@ -63,6 +80,9 @@ async def list_users(
(User.nickname.ilike(f"%{search}%")) (User.nickname.ilike(f"%{search}%"))
) )
if banned_only:
query = query.where(User.is_banned == True)
query = query.offset(skip).limit(limit) query = query.offset(skip).limit(limit)
result = await db.execute(query) result = await db.execute(query)
users = result.scalars().all() users = result.scalars().all()
@@ -83,6 +103,10 @@ async def list_users(
telegram_username=user.telegram_username, telegram_username=user.telegram_username,
marathons_count=marathons_count, marathons_count=marathons_count,
created_at=user.created_at.isoformat(), created_at=user.created_at.isoformat(),
is_banned=user.is_banned,
banned_at=user.banned_at.isoformat() if user.banned_at else None,
banned_until=user.banned_until.isoformat() if user.banned_until else None,
ban_reason=user.ban_reason,
)) ))
return response return response
@@ -91,7 +115,7 @@ async def list_users(
@router.get("/users/{user_id}", response_model=AdminUserResponse) @router.get("/users/{user_id}", response_model=AdminUserResponse)
async def get_user(user_id: int, current_user: CurrentUser, db: DbSession): async def get_user(user_id: int, current_user: CurrentUser, db: DbSession):
"""Get user details. Admin only.""" """Get user details. Admin only."""
require_admin(current_user) require_admin_with_2fa(current_user)
result = await db.execute(select(User).where(User.id == user_id)) result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@@ -112,6 +136,10 @@ async def get_user(user_id: int, current_user: CurrentUser, db: DbSession):
telegram_username=user.telegram_username, telegram_username=user.telegram_username,
marathons_count=marathons_count, marathons_count=marathons_count,
created_at=user.created_at.isoformat(), created_at=user.created_at.isoformat(),
is_banned=user.is_banned,
banned_at=user.banned_at.isoformat() if user.banned_at else None,
banned_until=user.banned_until.isoformat() if user.banned_until else None,
ban_reason=user.ban_reason,
) )
@@ -121,9 +149,10 @@ async def set_user_role(
data: SetUserRole, data: SetUserRole,
current_user: CurrentUser, current_user: CurrentUser,
db: DbSession, db: DbSession,
request: Request,
): ):
"""Set user's global role. Admin only.""" """Set user's global role. Admin only."""
require_admin(current_user) require_admin_with_2fa(current_user)
# Cannot change own role # Cannot change own role
if user_id == current_user.id: if user_id == current_user.id:
@@ -134,10 +163,19 @@ async def set_user_role(
if not user: if not user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
old_role = user.role
user.role = data.role user.role = data.role
await db.commit() await db.commit()
await db.refresh(user) await db.refresh(user)
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.USER_ROLE_CHANGE.value,
"user", user_id,
{"old_role": old_role, "new_role": data.role, "nickname": user.nickname},
request.client.host if request.client else None
)
marathons_count = await db.scalar( marathons_count = await db.scalar(
select(func.count()).select_from(Participant).where(Participant.user_id == user.id) select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
) )
@@ -152,13 +190,17 @@ async def set_user_role(
telegram_username=user.telegram_username, telegram_username=user.telegram_username,
marathons_count=marathons_count, marathons_count=marathons_count,
created_at=user.created_at.isoformat(), created_at=user.created_at.isoformat(),
is_banned=user.is_banned,
banned_at=user.banned_at.isoformat() if user.banned_at else None,
banned_until=user.banned_until.isoformat() if user.banned_until else None,
ban_reason=user.ban_reason,
) )
@router.delete("/users/{user_id}", response_model=MessageResponse) @router.delete("/users/{user_id}", response_model=MessageResponse)
async def delete_user(user_id: int, current_user: CurrentUser, db: DbSession): async def delete_user(user_id: int, current_user: CurrentUser, db: DbSession):
"""Delete a user. Admin only.""" """Delete a user. Admin only."""
require_admin(current_user) require_admin_with_2fa(current_user)
# Cannot delete yourself # Cannot delete yourself
if user_id == current_user.id: if user_id == current_user.id:
@@ -188,7 +230,7 @@ async def list_marathons(
search: str | None = None, search: str | None = None,
): ):
"""List all marathons. Admin only.""" """List all marathons. Admin only."""
require_admin(current_user) require_admin_with_2fa(current_user)
query = ( query = (
select(Marathon) select(Marathon)
@@ -227,25 +269,34 @@ async def list_marathons(
@router.delete("/marathons/{marathon_id}", response_model=MessageResponse) @router.delete("/marathons/{marathon_id}", response_model=MessageResponse)
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession, request: Request):
"""Delete a marathon. Admin only.""" """Delete a marathon. Admin only."""
require_admin(current_user) require_admin_with_2fa(current_user)
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id)) result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none() marathon = result.scalar_one_or_none()
if not marathon: if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found") raise HTTPException(status_code=404, detail="Marathon not found")
marathon_title = marathon.title
await db.delete(marathon) await db.delete(marathon)
await db.commit() await db.commit()
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.MARATHON_DELETE.value,
"marathon", marathon_id,
{"title": marathon_title},
request.client.host if request.client else None
)
return MessageResponse(message="Marathon deleted") return MessageResponse(message="Marathon deleted")
@router.get("/stats") @router.get("/stats")
async def get_stats(current_user: CurrentUser, db: DbSession): async def get_stats(current_user: CurrentUser, db: DbSession):
"""Get platform statistics. Admin only.""" """Get platform statistics. Admin only."""
require_admin(current_user) require_admin_with_2fa(current_user)
users_count = await db.scalar(select(func.count()).select_from(User)) users_count = await db.scalar(select(func.count()).select_from(User))
marathons_count = await db.scalar(select(func.count()).select_from(Marathon)) marathons_count = await db.scalar(select(func.count()).select_from(Marathon))
@@ -258,3 +309,439 @@ async def get_stats(current_user: CurrentUser, db: DbSession):
"games_count": games_count, "games_count": games_count,
"total_participations": participants_count, "total_participations": participants_count,
} }
# ============ Ban/Unban Users ============
@router.post("/users/{user_id}/ban", response_model=AdminUserResponse)
@limiter.limit("10/minute")
async def ban_user(
request: Request,
user_id: int,
data: BanUserRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Ban a user. Admin only."""
require_admin_with_2fa(current_user)
if user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot ban yourself")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.role == UserRole.ADMIN.value:
raise HTTPException(status_code=400, detail="Cannot ban another admin")
if user.is_banned:
raise HTTPException(status_code=400, detail="User is already banned")
user.is_banned = True
user.banned_at = datetime.utcnow()
# Normalize to naive datetime (remove tzinfo) to match banned_at
user.banned_until = data.banned_until.replace(tzinfo=None) if data.banned_until else None
user.banned_by_id = current_user.id
user.ban_reason = data.reason
await db.commit()
await db.refresh(user)
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.USER_BAN.value,
"user", user_id,
{"nickname": user.nickname, "reason": data.reason},
request.client.host if request.client else None
)
marathons_count = await db.scalar(
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
)
return AdminUserResponse(
id=user.id,
login=user.login,
nickname=user.nickname,
role=user.role,
avatar_url=user.avatar_url,
telegram_id=user.telegram_id,
telegram_username=user.telegram_username,
marathons_count=marathons_count,
created_at=user.created_at.isoformat(),
is_banned=user.is_banned,
banned_at=user.banned_at.isoformat() if user.banned_at else None,
banned_until=user.banned_until.isoformat() if user.banned_until else None,
ban_reason=user.ban_reason,
)
@router.post("/users/{user_id}/unban", response_model=AdminUserResponse)
async def unban_user(
request: Request,
user_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Unban a user. Admin only."""
require_admin_with_2fa(current_user)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not user.is_banned:
raise HTTPException(status_code=400, detail="User is not banned")
user.is_banned = False
user.banned_at = None
user.banned_until = None
user.banned_by_id = None
user.ban_reason = None
await db.commit()
await db.refresh(user)
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.USER_UNBAN.value,
"user", user_id,
{"nickname": user.nickname},
request.client.host if request.client else None
)
marathons_count = await db.scalar(
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
)
return AdminUserResponse(
id=user.id,
login=user.login,
nickname=user.nickname,
role=user.role,
avatar_url=user.avatar_url,
telegram_id=user.telegram_id,
telegram_username=user.telegram_username,
marathons_count=marathons_count,
created_at=user.created_at.isoformat(),
is_banned=user.is_banned,
banned_at=None,
banned_until=None,
ban_reason=None,
)
# ============ Force Finish Marathon ============
@router.post("/marathons/{marathon_id}/force-finish", response_model=MessageResponse)
async def force_finish_marathon(
request: Request,
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Force finish a marathon. Admin only."""
require_admin_with_2fa(current_user)
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
if marathon.status == MarathonStatus.FINISHED.value:
raise HTTPException(status_code=400, detail="Marathon is already finished")
old_status = marathon.status
marathon.status = MarathonStatus.FINISHED.value
marathon.end_date = datetime.utcnow()
await db.commit()
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.MARATHON_FORCE_FINISH.value,
"marathon", marathon_id,
{"title": marathon.title, "old_status": old_status},
request.client.host if request.client else None
)
# Notify participants
await telegram_notifier.notify_marathon_finish(db, marathon_id, marathon.title)
return MessageResponse(message="Marathon finished")
# ============ Admin Logs ============
@router.get("/logs", response_model=AdminLogsListResponse)
async def get_logs(
current_user: CurrentUser,
db: DbSession,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
action: str | None = None,
admin_id: int | None = None,
):
"""Get admin action logs. Admin only."""
require_admin_with_2fa(current_user)
query = (
select(AdminLog)
.options(selectinload(AdminLog.admin))
.order_by(AdminLog.created_at.desc())
)
if action:
query = query.where(AdminLog.action == action)
if admin_id:
query = query.where(AdminLog.admin_id == admin_id)
# Get total count
count_query = select(func.count()).select_from(AdminLog)
if action:
count_query = count_query.where(AdminLog.action == action)
if admin_id:
count_query = count_query.where(AdminLog.admin_id == admin_id)
total = await db.scalar(count_query)
query = query.offset(skip).limit(limit)
result = await db.execute(query)
logs = result.scalars().all()
return AdminLogsListResponse(
logs=[
AdminLogResponse(
id=log.id,
admin_id=log.admin_id,
admin_nickname=log.admin.nickname if log.admin else None,
action=log.action,
target_type=log.target_type,
target_id=log.target_id,
details=log.details,
ip_address=log.ip_address,
created_at=log.created_at,
)
for log in logs
],
total=total or 0,
)
# ============ Broadcast ============
@router.post("/broadcast/all", response_model=BroadcastResponse)
@limiter.limit("1/minute")
async def broadcast_to_all(
request: Request,
data: BroadcastRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Send broadcast message to all users with Telegram linked. Admin only."""
require_admin_with_2fa(current_user)
# Get all users with telegram_id
result = await db.execute(
select(User).where(User.telegram_id.isnot(None))
)
users = result.scalars().all()
total_count = len(users)
sent_count = 0
for user in users:
if await telegram_notifier.send_message(user.telegram_id, data.message):
sent_count += 1
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.BROADCAST_ALL.value,
"broadcast", 0,
{"message": data.message[:100], "sent": sent_count, "total": total_count},
request.client.host if request.client else None
)
return BroadcastResponse(sent_count=sent_count, total_count=total_count)
@router.post("/broadcast/marathon/{marathon_id}", response_model=BroadcastResponse)
@limiter.limit("3/minute")
async def broadcast_to_marathon(
request: Request,
marathon_id: int,
data: BroadcastRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Send broadcast message to marathon participants. Admin only."""
require_admin_with_2fa(current_user)
# Check marathon exists
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
# Get participants count
total_result = await db.execute(
select(User)
.join(Participant, Participant.user_id == User.id)
.where(
Participant.marathon_id == marathon_id,
User.telegram_id.isnot(None)
)
)
users = total_result.scalars().all()
total_count = len(users)
sent_count = await telegram_notifier.notify_marathon_participants(
db, marathon_id, data.message
)
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.BROADCAST_MARATHON.value,
"marathon", marathon_id,
{"title": marathon.title, "message": data.message[:100], "sent": sent_count, "total": total_count},
request.client.host if request.client else None
)
return BroadcastResponse(sent_count=sent_count, total_count=total_count)
# ============ Static Content ============
@router.get("/content", response_model=list[StaticContentResponse])
async def list_content(current_user: CurrentUser, db: DbSession):
"""List all static content. Admin only."""
require_admin_with_2fa(current_user)
result = await db.execute(
select(StaticContent).order_by(StaticContent.key)
)
return result.scalars().all()
@router.get("/content/{key}", response_model=StaticContentResponse)
async def get_content(key: str, current_user: CurrentUser, db: DbSession):
"""Get static content by key. Admin only."""
require_admin_with_2fa(current_user)
result = await db.execute(
select(StaticContent).where(StaticContent.key == key)
)
content = result.scalar_one_or_none()
if not content:
raise HTTPException(status_code=404, detail="Content not found")
return content
@router.put("/content/{key}", response_model=StaticContentResponse)
async def update_content(
request: Request,
key: str,
data: StaticContentUpdate,
current_user: CurrentUser,
db: DbSession,
):
"""Update static content. Admin only."""
require_admin_with_2fa(current_user)
result = await db.execute(
select(StaticContent).where(StaticContent.key == key)
)
content = result.scalar_one_or_none()
if not content:
raise HTTPException(status_code=404, detail="Content not found")
content.title = data.title
content.content = data.content
content.updated_by_id = current_user.id
content.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(content)
# Log action
await log_admin_action(
db, current_user.id, AdminActionType.CONTENT_UPDATE.value,
"content", content.id,
{"key": key, "title": data.title},
request.client.host if request.client else None
)
return content
@router.post("/content", response_model=StaticContentResponse)
async def create_content(
request: Request,
data: StaticContentCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Create static content. Admin only."""
require_admin_with_2fa(current_user)
# Check if key exists
result = await db.execute(
select(StaticContent).where(StaticContent.key == data.key)
)
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Content with this key already exists")
content = StaticContent(
key=data.key,
title=data.title,
content=data.content,
updated_by_id=current_user.id,
)
db.add(content)
await db.commit()
await db.refresh(content)
return content
# ============ Dashboard ============
@router.get("/dashboard", response_model=DashboardStats)
async def get_dashboard(current_user: CurrentUser, db: DbSession):
"""Get dashboard statistics. Admin only."""
require_admin_with_2fa(current_user)
users_count = await db.scalar(select(func.count()).select_from(User))
banned_users_count = await db.scalar(
select(func.count()).select_from(User).where(User.is_banned == True)
)
marathons_count = await db.scalar(select(func.count()).select_from(Marathon))
active_marathons_count = await db.scalar(
select(func.count()).select_from(Marathon).where(Marathon.status == MarathonStatus.ACTIVE.value)
)
games_count = await db.scalar(select(func.count()).select_from(Game))
total_participations = await db.scalar(select(func.count()).select_from(Participant))
# Get recent logs
result = await db.execute(
select(AdminLog)
.options(selectinload(AdminLog.admin))
.order_by(AdminLog.created_at.desc())
.limit(10)
)
recent_logs = result.scalars().all()
return DashboardStats(
users_count=users_count or 0,
banned_users_count=banned_users_count or 0,
marathons_count=marathons_count or 0,
active_marathons_count=active_marathons_count or 0,
games_count=games_count or 0,
total_participations=total_participations or 0,
recent_logs=[
AdminLogResponse(
id=log.id,
admin_id=log.admin_id,
admin_nickname=log.admin.nickname if log.admin else None,
action=log.action,
target_type=log.target_type,
target_id=log.target_id,
details=log.details,
ip_address=log.ip_address,
created_at=log.created_at,
)
for log in recent_logs
],
)

View File

@@ -1,16 +1,22 @@
from fastapi import APIRouter, HTTPException, status from datetime import datetime, timedelta
import secrets
from fastapi import APIRouter, HTTPException, status, Request
from sqlalchemy import select from sqlalchemy import select
from app.api.deps import DbSession, CurrentUser from app.api.deps import DbSession, CurrentUser
from app.core.security import verify_password, get_password_hash, create_access_token from app.core.security import verify_password, get_password_hash, create_access_token
from app.models import User from app.core.rate_limit import limiter
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPublic from app.models import User, UserRole, Admin2FASession
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate, LoginResponse
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=TokenResponse) @router.post("/register", response_model=TokenResponse)
async def register(data: UserRegister, db: DbSession): @limiter.limit("5/minute")
async def register(request: Request, data: UserRegister, db: DbSession):
# Check if login already exists # Check if login already exists
result = await db.execute(select(User).where(User.login == data.login.lower())) result = await db.execute(select(User).where(User.login == data.login.lower()))
if result.scalar_one_or_none(): if result.scalar_one_or_none():
@@ -34,12 +40,13 @@ async def register(data: UserRegister, db: DbSession):
return TokenResponse( return TokenResponse(
access_token=access_token, access_token=access_token,
user=UserPublic.model_validate(user), user=UserPrivate.model_validate(user),
) )
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=LoginResponse)
async def login(data: UserLogin, db: DbSession): @limiter.limit("10/minute")
async def login(request: Request, data: UserLogin, db: DbSession):
# Find user # Find user
result = await db.execute(select(User).where(User.login == data.login.lower())) result = await db.execute(select(User).where(User.login == data.login.lower()))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@@ -50,15 +57,109 @@ async def login(data: UserLogin, db: DbSession):
detail="Incorrect login or password", detail="Incorrect login or password",
) )
# Check if user is banned
if user.is_banned:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Your account has been banned",
)
# If admin with Telegram linked, require 2FA
if user.role == UserRole.ADMIN.value and user.telegram_id:
# Generate 6-digit code
code = "".join([str(secrets.randbelow(10)) for _ in range(6)])
# Create 2FA session (expires in 5 minutes)
session = Admin2FASession(
user_id=user.id,
code=code,
expires_at=datetime.utcnow() + timedelta(minutes=5),
)
db.add(session)
await db.commit()
await db.refresh(session)
# Send code to Telegram
message = f"🔐 <b>Код подтверждения для входа в админку</b>\n\nВаш код: <code>{code}</code>\n\nКод действителен 5 минут."
sent = await telegram_notifier.send_message(user.telegram_id, message)
if sent:
session.telegram_sent = True
await db.commit()
return LoginResponse(
requires_2fa=True,
two_factor_session_id=session.id,
)
# Regular user or admin without Telegram - generate token immediately
# Admin without Telegram can login but admin panel will check for Telegram
access_token = create_access_token(subject=user.id)
return LoginResponse(
access_token=access_token,
user=UserPrivate.model_validate(user),
)
@router.post("/2fa/verify", response_model=TokenResponse)
@limiter.limit("5/minute")
async def verify_2fa(request: Request, session_id: int, code: str, db: DbSession):
"""Verify 2FA code and return JWT token."""
# Find session
result = await db.execute(
select(Admin2FASession).where(Admin2FASession.id == session_id)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid session",
)
if session.is_verified:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Session already verified",
)
if datetime.utcnow() > session.expires_at:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Code expired",
)
if session.code != code:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid code",
)
# Mark as verified
session.is_verified = True
await db.commit()
# Get user
result = await db.execute(select(User).where(User.id == session.user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User not found",
)
# Generate token # Generate token
access_token = create_access_token(subject=user.id) access_token = create_access_token(subject=user.id)
return TokenResponse( return TokenResponse(
access_token=access_token, access_token=access_token,
user=UserPublic.model_validate(user), user=UserPrivate.model_validate(user),
) )
@router.get("/me", response_model=UserPublic) @router.get("/me", response_model=UserPrivate)
async def get_me(current_user: CurrentUser): async def get_me(current_user: CurrentUser):
return UserPublic.model_validate(current_user) """Get current user's full profile (including private data)"""
return UserPrivate.model_validate(current_user)

View File

@@ -3,7 +3,8 @@ from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser, require_participant, require_organizer, get_participant from app.api.deps import DbSession, CurrentUser, require_participant, require_organizer, get_participant
from app.models import Marathon, MarathonStatus, Game, GameStatus, Challenge from app.models import Marathon, MarathonStatus, Game, GameStatus, Challenge, User
from app.models.challenge import ChallengeStatus
from app.schemas import ( from app.schemas import (
ChallengeCreate, ChallengeCreate,
ChallengeUpdate, ChallengeUpdate,
@@ -13,8 +14,11 @@ from app.schemas import (
ChallengePreview, ChallengePreview,
ChallengesPreviewResponse, ChallengesPreviewResponse,
ChallengesSaveRequest, ChallengesSaveRequest,
ChallengesGenerateRequest,
) )
from app.schemas.challenge import ChallengePropose, ProposedByUser
from app.services.gpt import gpt_service from app.services.gpt import gpt_service
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(tags=["challenges"]) router = APIRouter(tags=["challenges"])
@@ -22,7 +26,7 @@ router = APIRouter(tags=["challenges"])
async def get_challenge_or_404(db, challenge_id: int) -> Challenge: async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
result = await db.execute( result = await db.execute(
select(Challenge) select(Challenge)
.options(selectinload(Challenge.game)) .options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
.where(Challenge.id == challenge_id) .where(Challenge.id == challenge_id)
) )
challenge = result.scalar_one_or_none() challenge = result.scalar_one_or_none()
@@ -31,9 +35,36 @@ async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
return challenge return challenge
def build_challenge_response(challenge: Challenge, game: Game) -> ChallengeResponse:
"""Helper to build ChallengeResponse with proposed_by"""
proposed_by = None
if challenge.proposed_by:
proposed_by = ProposedByUser(
id=challenge.proposed_by.id,
nickname=challenge.proposed_by.nickname
)
return ChallengeResponse(
id=challenge.id,
title=challenge.title,
description=challenge.description,
type=challenge.type,
difficulty=challenge.difficulty,
points=challenge.points,
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
status=challenge.status,
proposed_by=proposed_by,
)
@router.get("/games/{game_id}/challenges", response_model=list[ChallengeResponse]) @router.get("/games/{game_id}/challenges", response_model=list[ChallengeResponse])
async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession): async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession):
"""List challenges for a game. Participants can view challenges for approved games only.""" """List challenges for a game. Participants can view approved and pending challenges."""
# Get game and check access # Get game and check access
result = await db.execute( result = await db.execute(
select(Game).where(Game.id == game_id) select(Game).where(Game.id == game_id)
@@ -53,30 +84,17 @@ async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession
if game.status != GameStatus.APPROVED.value and game.proposed_by_id != current_user.id: if game.status != GameStatus.APPROVED.value and game.proposed_by_id != current_user.id:
raise HTTPException(status_code=403, detail="Game not accessible") raise HTTPException(status_code=403, detail="Game not accessible")
result = await db.execute( # Get challenges with proposed_by
select(Challenge) query = select(Challenge).options(selectinload(Challenge.proposed_by)).where(Challenge.game_id == game_id)
.where(Challenge.game_id == game_id)
.order_by(Challenge.difficulty, Challenge.created_at) # Regular participants see approved and pending challenges (but not rejected)
) if not current_user.is_admin and participant and not participant.is_organizer:
query = query.where(Challenge.status.in_([ChallengeStatus.APPROVED.value, ChallengeStatus.PENDING.value]))
result = await db.execute(query.order_by(Challenge.status.desc(), Challenge.difficulty, Challenge.created_at))
challenges = result.scalars().all() challenges = result.scalars().all()
return [ return [build_challenge_response(c, game) for c in challenges]
ChallengeResponse(
id=c.id,
title=c.title,
description=c.description,
type=c.type,
difficulty=c.difficulty,
points=c.points,
estimated_time=c.estimated_time,
proof_type=c.proof_type,
proof_hint=c.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None),
is_generated=c.is_generated,
created_at=c.created_at,
)
for c in challenges
]
@router.get("/marathons/{marathon_id}/challenges", response_model=list[ChallengeResponse]) @router.get("/marathons/{marathon_id}/challenges", response_model=list[ChallengeResponse])
@@ -93,36 +111,21 @@ async def list_marathon_challenges(marathon_id: int, current_user: CurrentUser,
if not current_user.is_admin and not participant: if not current_user.is_admin and not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon") raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Get all challenges from approved games in this marathon # Get all approved challenges from approved games in this marathon
result = await db.execute( result = await db.execute(
select(Challenge) select(Challenge)
.join(Game, Challenge.game_id == Game.id) .join(Game, Challenge.game_id == Game.id)
.options(selectinload(Challenge.game)) .options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
.where( .where(
Game.marathon_id == marathon_id, Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value, Game.status == GameStatus.APPROVED.value,
Challenge.status == ChallengeStatus.APPROVED.value,
) )
.order_by(Game.title, Challenge.difficulty, Challenge.created_at) .order_by(Game.title, Challenge.difficulty, Challenge.created_at)
) )
challenges = result.scalars().all() challenges = result.scalars().all()
return [ return [build_challenge_response(c, c.game) for c in challenges]
ChallengeResponse(
id=c.id,
title=c.title,
description=c.description,
type=c.type,
difficulty=c.difficulty,
points=c.points,
estimated_time=c.estimated_time,
proof_type=c.proof_type,
proof_hint=c.proof_hint,
game=GameShort(id=c.game.id, title=c.game.title, cover_url=None),
is_generated=c.is_generated,
created_at=c.created_at,
)
for c in challenges
]
@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse) @router.post("/games/{game_id}/challenges", response_model=ChallengeResponse)
@@ -165,29 +168,22 @@ async def create_challenge(
proof_type=data.proof_type.value, proof_type=data.proof_type.value,
proof_hint=data.proof_hint, proof_hint=data.proof_hint,
is_generated=False, is_generated=False,
status=ChallengeStatus.APPROVED.value, # Organizer-created challenges are auto-approved
) )
db.add(challenge) db.add(challenge)
await db.commit() await db.commit()
await db.refresh(challenge) await db.refresh(challenge)
return ChallengeResponse( return build_challenge_response(challenge, game)
id=challenge.id,
title=challenge.title,
description=challenge.description,
type=challenge.type,
difficulty=challenge.difficulty,
points=challenge.points,
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
)
@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse) @router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse)
async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession): async def preview_challenges(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
data: ChallengesGenerateRequest | None = None,
):
"""Generate challenges preview for approved games in marathon using GPT (without saving). Organizers only.""" """Generate challenges preview for approved games in marathon using GPT (without saving). Organizers only."""
# Check marathon # Check marathon
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id)) result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
@@ -202,21 +198,35 @@ async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: Db
await require_organizer(db, current_user, marathon_id) await require_organizer(db, current_user, marathon_id)
# Get only APPROVED games # Get only APPROVED games
result = await db.execute( query = select(Game).where(
select(Game).where(
Game.marathon_id == marathon_id, Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value, Game.status == GameStatus.APPROVED.value,
) )
)
# Filter by specific game IDs if provided
if data and data.game_ids:
query = query.where(Game.id.in_(data.game_ids))
result = await db.execute(query)
games = result.scalars().all() games = result.scalars().all()
if not games: if not games:
raise HTTPException(status_code=400, detail="No approved games in marathon") raise HTTPException(status_code=400, detail="No approved games found")
# Filter games that don't have challenges yet # Build games list for generation (skip games that already have challenges, unless specific IDs requested)
games_to_generate = [] games_to_generate = []
game_map = {} game_map = {}
for game in games: for game in games:
# If specific games requested, generate even if they have challenges
if data and data.game_ids:
games_to_generate.append({
"id": game.id,
"title": game.title,
"genre": game.genre
})
game_map[game.id] = game.title
else:
# Otherwise only generate for games without challenges
existing = await db.scalar( existing = await db.scalar(
select(Challenge.id).where(Challenge.game_id == game.id).limit(1) select(Challenge.id).where(Challenge.game_id == game.id).limit(1)
) )
@@ -366,26 +376,12 @@ async def update_challenge(
await db.commit() await db.commit()
await db.refresh(challenge) await db.refresh(challenge)
game = challenge.game return build_challenge_response(challenge, challenge.game)
return ChallengeResponse(
id=challenge.id,
title=challenge.title,
description=challenge.description,
type=challenge.type,
difficulty=challenge.difficulty,
points=challenge.points,
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
)
@router.delete("/challenges/{challenge_id}", response_model=MessageResponse) @router.delete("/challenges/{challenge_id}", response_model=MessageResponse)
async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession): async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
"""Delete a challenge. Organizers only.""" """Delete a challenge. Organizers can delete any, participants can delete their own pending."""
challenge = await get_challenge_or_404(db, challenge_id) challenge = await get_challenge_or_404(db, challenge_id)
# Check marathon is in preparing state # Check marathon is in preparing state
@@ -394,10 +390,206 @@ async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbS
if marathon.status != MarathonStatus.PREPARING.value: if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot delete challenges from active or finished marathon") raise HTTPException(status_code=400, detail="Cannot delete challenges from active or finished marathon")
# Only organizers can delete challenges participant = await get_participant(db, current_user.id, challenge.game.marathon_id)
await require_organizer(db, current_user, challenge.game.marathon_id)
# Check permissions
if current_user.is_admin or (participant and participant.is_organizer):
# Organizers can delete any challenge
pass
elif challenge.proposed_by_id == current_user.id and challenge.status == ChallengeStatus.PENDING.value:
# Participants can delete their own pending challenges
pass
else:
raise HTTPException(status_code=403, detail="You can only delete your own pending challenges")
await db.delete(challenge) await db.delete(challenge)
await db.commit() await db.commit()
return MessageResponse(message="Challenge deleted") return MessageResponse(message="Challenge deleted")
# ============ Proposed challenges endpoints ============
@router.post("/games/{game_id}/propose-challenge", response_model=ChallengeResponse)
async def propose_challenge(
game_id: int,
data: ChallengePropose,
current_user: CurrentUser,
db: DbSession,
):
"""Propose a challenge for a game. Participants only, during PREPARING phase."""
# Get game
result = await db.execute(select(Game).where(Game.id == game_id))
game = result.scalar_one_or_none()
if not game:
raise HTTPException(status_code=404, detail="Game not found")
# Check marathon is in preparing state
result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
marathon = result.scalar_one()
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot propose challenges to active or finished marathon")
# Check user is participant
participant = await get_participant(db, current_user.id, game.marathon_id)
if not participant and not current_user.is_admin:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Can only propose challenges to approved games
if game.status != GameStatus.APPROVED.value:
raise HTTPException(status_code=400, detail="Can only propose challenges to approved games")
challenge = Challenge(
game_id=game_id,
title=data.title,
description=data.description,
type=data.type.value,
difficulty=data.difficulty.value,
points=data.points,
estimated_time=data.estimated_time,
proof_type=data.proof_type.value,
proof_hint=data.proof_hint,
is_generated=False,
proposed_by_id=current_user.id,
status=ChallengeStatus.PENDING.value,
)
db.add(challenge)
await db.commit()
await db.refresh(challenge)
# Load proposed_by relationship
challenge.proposed_by = current_user
return build_challenge_response(challenge, game)
@router.get("/marathons/{marathon_id}/proposed-challenges", response_model=list[ChallengeResponse])
async def list_proposed_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""List all pending proposed challenges for a marathon. Organizers only."""
# Check marathon exists
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
# Only organizers can see all proposed challenges
await require_organizer(db, current_user, marathon_id)
# Get all pending challenges from approved games
result = await db.execute(
select(Challenge)
.join(Game, Challenge.game_id == Game.id)
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
.where(
Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value,
Challenge.status == ChallengeStatus.PENDING.value,
)
.order_by(Challenge.created_at.desc())
)
challenges = result.scalars().all()
return [build_challenge_response(c, c.game) for c in challenges]
@router.get("/marathons/{marathon_id}/my-proposed-challenges", response_model=list[ChallengeResponse])
async def list_my_proposed_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""List current user's proposed challenges for a marathon."""
# Check marathon exists
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if not marathon:
raise HTTPException(status_code=404, detail="Marathon not found")
# Check user is participant
participant = await get_participant(db, current_user.id, marathon_id)
if not participant and not current_user.is_admin:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Get user's proposed challenges
result = await db.execute(
select(Challenge)
.join(Game, Challenge.game_id == Game.id)
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
.where(
Game.marathon_id == marathon_id,
Challenge.proposed_by_id == current_user.id,
)
.order_by(Challenge.created_at.desc())
)
challenges = result.scalars().all()
return [build_challenge_response(c, c.game) for c in challenges]
@router.patch("/challenges/{challenge_id}/approve", response_model=ChallengeResponse)
async def approve_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
"""Approve a proposed challenge. Organizers only."""
challenge = await get_challenge_or_404(db, challenge_id)
# Check marathon is in preparing state
result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id))
marathon = result.scalar_one()
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot approve challenges in active or finished marathon")
# Only organizers can approve
await require_organizer(db, current_user, challenge.game.marathon_id)
if challenge.status != ChallengeStatus.PENDING.value:
raise HTTPException(status_code=400, detail="Challenge is not pending")
challenge.status = ChallengeStatus.APPROVED.value
await db.commit()
await db.refresh(challenge)
# Send Telegram notification to proposer
if challenge.proposed_by_id:
await telegram_notifier.notify_challenge_approved(
db,
challenge.proposed_by_id,
marathon.title,
challenge.game.title,
challenge.title
)
return build_challenge_response(challenge, challenge.game)
@router.patch("/challenges/{challenge_id}/reject", response_model=ChallengeResponse)
async def reject_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
"""Reject a proposed challenge. Organizers only."""
challenge = await get_challenge_or_404(db, challenge_id)
# Check marathon is in preparing state
result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id))
marathon = result.scalar_one()
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot reject challenges in active or finished marathon")
# Only organizers can reject
await require_organizer(db, current_user, challenge.game.marathon_id)
if challenge.status != ChallengeStatus.PENDING.value:
raise HTTPException(status_code=400, detail="Challenge is not pending")
# Save info for notification before changing status
proposer_id = challenge.proposed_by_id
game_title = challenge.game.title
challenge_title = challenge.title
challenge.status = ChallengeStatus.REJECTED.value
await db.commit()
await db.refresh(challenge)
# Send Telegram notification to proposer
if proposer_id:
await telegram_notifier.notify_challenge_rejected(
db,
proposer_id,
marathon.title,
game_title,
challenge_title
)
return build_challenge_response(challenge, challenge.game)

View File

@@ -0,0 +1,20 @@
from fastapi import APIRouter, HTTPException
from sqlalchemy import select
from app.api.deps import DbSession
from app.models import StaticContent
from app.schemas import StaticContentResponse
router = APIRouter(prefix="/content", tags=["content"])
@router.get("/{key}", response_model=StaticContentResponse)
async def get_public_content(key: str, db: DbSession):
"""Get public static content by key. No authentication required."""
result = await db.execute(
select(StaticContent).where(StaticContent.key == key)
)
content = result.scalar_one_or_none()
if not content:
raise HTTPException(status_code=404, detail="Content not found")
return content

View File

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

View File

@@ -1,6 +1,8 @@
from datetime import timedelta from datetime import timedelta
import secrets import secrets
from fastapi import APIRouter, HTTPException, status import string
from fastapi import APIRouter, HTTPException, status, Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -9,8 +11,12 @@ from app.api.deps import (
require_participant, require_organizer, require_creator, require_participant, require_organizer, require_creator,
get_participant, get_participant,
) )
from app.core.security import decode_access_token
# Optional auth for endpoints that need it conditionally
optional_auth = HTTPBearer(auto_error=False)
from app.models import ( from app.models import (
Marathon, Participant, MarathonStatus, Game, GameStatus, Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole, Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
) )
from app.schemas import ( from app.schemas import (
@@ -40,7 +46,7 @@ async def get_marathon_by_code(invite_code: str, db: DbSession):
select(Marathon, func.count(Participant.id).label("participants_count")) select(Marathon, func.count(Participant.id).label("participants_count"))
.outerjoin(Participant) .outerjoin(Participant)
.options(selectinload(Marathon.creator)) .options(selectinload(Marathon.creator))
.where(Marathon.invite_code == invite_code) .where(func.upper(Marathon.invite_code) == invite_code.upper())
.group_by(Marathon.id) .group_by(Marathon.id)
) )
row = result.first() row = result.first()
@@ -62,7 +68,9 @@ async def get_marathon_by_code(invite_code: str, db: DbSession):
def generate_invite_code() -> str: def generate_invite_code() -> str:
return secrets.token_urlsafe(8) """Generate a clean 8-character uppercase alphanumeric code."""
alphabet = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(8))
async def get_marathon_or_404(db, marathon_id: int) -> Marathon: async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
@@ -185,6 +193,15 @@ async def create_marathon(
async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession): async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
marathon = await get_marathon_or_404(db, marathon_id) marathon = await get_marathon_or_404(db, marathon_id)
# For private marathons, require participation (or admin/creator)
if not marathon.is_public and not current_user.is_admin:
participation = await get_participation(db, current_user.id, marathon_id)
if not participation:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not a participant of this private marathon",
)
# Count participants and approved games # Count participants and approved games
participants_count = await db.scalar( participants_count = await db.scalar(
select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon_id) select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon_id)
@@ -272,15 +289,33 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
if marathon.status != MarathonStatus.PREPARING.value: if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Marathon is not in preparing state") raise HTTPException(status_code=400, detail="Marathon is not in preparing state")
# Check if there are approved games with challenges # Check if there are approved games
games_count = await db.scalar( games_result = await db.execute(
select(func.count()).select_from(Game).where( select(Game).where(
Game.marathon_id == marathon_id, Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value, Game.status == GameStatus.APPROVED.value,
) )
) )
if games_count == 0: approved_games = games_result.scalars().all()
raise HTTPException(status_code=400, detail="Add and approve at least one game before starting")
if len(approved_games) == 0:
raise HTTPException(status_code=400, detail="Добавьте и одобрите хотя бы одну игру")
# Check that all approved games have at least one challenge
games_without_challenges = []
for game in approved_games:
challenge_count = await db.scalar(
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
)
if challenge_count == 0:
games_without_challenges.append(game.title)
if games_without_challenges:
games_list = ", ".join(games_without_challenges)
raise HTTPException(
status_code=400,
detail=f"У следующих игр нет челленджей: {games_list}"
)
marathon.status = MarathonStatus.ACTIVE.value marathon.status = MarathonStatus.ACTIVE.value
@@ -332,7 +367,7 @@ async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSes
@router.post("/join", response_model=MarathonResponse) @router.post("/join", response_model=MarathonResponse)
async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSession): async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSession):
result = await db.execute( result = await db.execute(
select(Marathon).where(Marathon.invite_code == data.invite_code) select(Marathon).where(func.upper(Marathon.invite_code) == data.invite_code.upper())
) )
marathon = result.scalar_one_or_none() marathon = result.scalar_one_or_none()
@@ -407,7 +442,16 @@ async def join_public_marathon(marathon_id: int, current_user: CurrentUser, db:
@router.get("/{marathon_id}/participants", response_model=list[ParticipantWithUser]) @router.get("/{marathon_id}/participants", response_model=list[ParticipantWithUser])
async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSession): async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSession):
await get_marathon_or_404(db, marathon_id) marathon = await get_marathon_or_404(db, marathon_id)
# For private marathons, require participation (or admin)
if not marathon.is_public and not current_user.is_admin:
participation = await get_participation(db, current_user.id, marathon_id)
if not participation:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not a participant of this private marathon",
)
result = await db.execute( result = await db.execute(
select(Participant) select(Participant)
@@ -476,8 +520,42 @@ async def set_participant_role(
@router.get("/{marathon_id}/leaderboard", response_model=list[LeaderboardEntry]) @router.get("/{marathon_id}/leaderboard", response_model=list[LeaderboardEntry])
async def get_leaderboard(marathon_id: int, db: DbSession): async def get_leaderboard(
await get_marathon_or_404(db, marathon_id) marathon_id: int,
db: DbSession,
credentials: HTTPAuthorizationCredentials | None = Depends(optional_auth),
):
"""
Get marathon leaderboard.
Public marathons: no auth required.
Private marathons: requires auth + participation check.
"""
marathon = await get_marathon_or_404(db, marathon_id)
# For private marathons, require authentication and participation
if not marathon.is_public:
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required for private marathon leaderboard",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_access_token(credentials.credentials)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = int(payload.get("sub"))
participant = await get_participant(db, user_id, marathon_id)
if not participant:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not a participant of this marathon",
)
result = await db.execute( result = await db.execute(
select(Participant) select(Participant)

View File

@@ -5,7 +5,7 @@ from pydantic import BaseModel
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser from app.api.deps import DbSession, CurrentUser, BotSecretDep
from app.core.config import settings from app.core.config import settings
from app.core.security import create_telegram_link_token, verify_telegram_link_token from app.core.security import create_telegram_link_token, verify_telegram_link_token
from app.models import User, Participant, Marathon, Assignment, Challenge, Event, Game from app.models import User, Participant, Marathon, Assignment, Challenge, Event, Game
@@ -86,7 +86,7 @@ async def generate_link_token(current_user: CurrentUser):
) )
logger.info(f"[TG_LINK] Token created: {token} (length: {len(token)})") logger.info(f"[TG_LINK] Token created: {token} (length: {len(token)})")
bot_username = settings.TELEGRAM_BOT_USERNAME or "GameMarathonBot" bot_username = settings.TELEGRAM_BOT_USERNAME or "BCMarathonbot"
bot_url = f"https://t.me/{bot_username}?start={token}" bot_url = f"https://t.me/{bot_username}?start={token}"
logger.info(f"[TG_LINK] Bot URL: {bot_url}") logger.info(f"[TG_LINK] Bot URL: {bot_url}")
@@ -94,7 +94,7 @@ async def generate_link_token(current_user: CurrentUser):
@router.post("/confirm-link", response_model=TelegramLinkResponse) @router.post("/confirm-link", response_model=TelegramLinkResponse)
async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession): async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession, _: BotSecretDep):
"""Confirm Telegram account linking (called by bot).""" """Confirm Telegram account linking (called by bot)."""
logger.info(f"[TG_CONFIRM] ========== CONFIRM LINK REQUEST ==========") logger.info(f"[TG_CONFIRM] ========== CONFIRM LINK REQUEST ==========")
logger.info(f"[TG_CONFIRM] telegram_id: {data.telegram_id}") logger.info(f"[TG_CONFIRM] telegram_id: {data.telegram_id}")
@@ -145,7 +145,7 @@ async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession):
@router.get("/user/{telegram_id}", response_model=TelegramUserResponse | None) @router.get("/user/{telegram_id}", response_model=TelegramUserResponse | None)
async def get_user_by_telegram_id(telegram_id: int, db: DbSession): async def get_user_by_telegram_id(telegram_id: int, db: DbSession, _: BotSecretDep):
"""Get user by Telegram ID.""" """Get user by Telegram ID."""
logger.info(f"[TG_USER] Looking up user by telegram_id={telegram_id}") logger.info(f"[TG_USER] Looking up user by telegram_id={telegram_id}")
@@ -168,7 +168,7 @@ async def get_user_by_telegram_id(telegram_id: int, db: DbSession):
@router.post("/unlink/{telegram_id}", response_model=TelegramLinkResponse) @router.post("/unlink/{telegram_id}", response_model=TelegramLinkResponse)
async def unlink_telegram(telegram_id: int, db: DbSession): async def unlink_telegram(telegram_id: int, db: DbSession, _: BotSecretDep):
"""Unlink Telegram account.""" """Unlink Telegram account."""
result = await db.execute( result = await db.execute(
select(User).where(User.telegram_id == telegram_id) select(User).where(User.telegram_id == telegram_id)
@@ -187,7 +187,7 @@ async def unlink_telegram(telegram_id: int, db: DbSession):
@router.get("/marathons/{telegram_id}", response_model=list[TelegramMarathonResponse]) @router.get("/marathons/{telegram_id}", response_model=list[TelegramMarathonResponse])
async def get_user_marathons(telegram_id: int, db: DbSession): async def get_user_marathons(telegram_id: int, db: DbSession, _: BotSecretDep):
"""Get user's marathons by Telegram ID.""" """Get user's marathons by Telegram ID."""
# Get user # Get user
result = await db.execute( result = await db.execute(
@@ -231,7 +231,7 @@ async def get_user_marathons(telegram_id: int, db: DbSession):
@router.get("/marathon/{marathon_id}", response_model=TelegramMarathonDetails | None) @router.get("/marathon/{marathon_id}", response_model=TelegramMarathonDetails | None)
async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession): async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession, _: BotSecretDep):
"""Get marathon details for user by Telegram ID.""" """Get marathon details for user by Telegram ID."""
# Get user # Get user
result = await db.execute( result = await db.execute(
@@ -341,7 +341,7 @@ async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession
@router.get("/stats/{telegram_id}", response_model=TelegramStatsResponse | None) @router.get("/stats/{telegram_id}", response_model=TelegramStatsResponse | None)
async def get_user_stats(telegram_id: int, db: DbSession): async def get_user_stats(telegram_id: int, db: DbSession, _: BotSecretDep):
"""Get user's overall statistics by Telegram ID.""" """Get user's overall statistics by Telegram ID."""
# Get user # Get user
result = await db.execute( result = await db.execute(

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, HTTPException, status, UploadFile, File from fastapi import APIRouter, HTTPException, status, UploadFile, File, Response
from sqlalchemy import select, func from sqlalchemy import select, func
from app.api.deps import DbSession, CurrentUser from app.api.deps import DbSession, CurrentUser
@@ -8,7 +8,7 @@ from app.models import User, Participant, Assignment, Marathon
from app.models.assignment import AssignmentStatus from app.models.assignment import AssignmentStatus
from app.models.marathon import MarathonStatus from app.models.marathon import MarathonStatus
from app.schemas import ( from app.schemas import (
UserPublic, UserUpdate, TelegramLink, MessageResponse, UserPublic, UserPrivate, UserUpdate, TelegramLink, MessageResponse,
PasswordChange, UserStats, UserProfilePublic, PasswordChange, UserStats, UserProfilePublic,
) )
from app.services.storage import storage_service from app.services.storage import storage_service
@@ -17,7 +17,8 @@ router = APIRouter(prefix="/users", tags=["users"])
@router.get("/{user_id}", response_model=UserPublic) @router.get("/{user_id}", response_model=UserPublic)
async def get_user(user_id: int, db: DbSession): async def get_user(user_id: int, db: DbSession, current_user: CurrentUser):
"""Get user profile. Requires authentication."""
result = await db.execute(select(User).where(User.id == user_id)) result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@@ -30,23 +31,53 @@ async def get_user(user_id: int, db: DbSession):
return UserPublic.model_validate(user) return UserPublic.model_validate(user)
@router.patch("/me", response_model=UserPublic) @router.get("/{user_id}/avatar")
async def get_user_avatar(user_id: int, db: DbSession):
"""Stream user avatar from storage"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not user.avatar_path:
raise HTTPException(status_code=404, detail="User has no avatar")
# Get file from storage
file_data = await storage_service.get_file(user.avatar_path, "avatars")
if not file_data:
raise HTTPException(status_code=404, detail="Avatar not found in storage")
content, content_type = file_data
return Response(
content=content,
media_type=content_type,
headers={
"Cache-Control": "public, max-age=3600",
}
)
@router.patch("/me", response_model=UserPrivate)
async def update_me(data: UserUpdate, current_user: CurrentUser, db: DbSession): async def update_me(data: UserUpdate, current_user: CurrentUser, db: DbSession):
"""Update current user's profile"""
if data.nickname is not None: if data.nickname is not None:
current_user.nickname = data.nickname current_user.nickname = data.nickname
await db.commit() await db.commit()
await db.refresh(current_user) await db.refresh(current_user)
return UserPublic.model_validate(current_user) return UserPrivate.model_validate(current_user)
@router.post("/me/avatar", response_model=UserPublic) @router.post("/me/avatar", response_model=UserPrivate)
async def upload_avatar( async def upload_avatar(
current_user: CurrentUser, current_user: CurrentUser,
db: DbSession, db: DbSession,
file: UploadFile = File(...), file: UploadFile = File(...),
): ):
"""Upload current user's avatar"""
# Validate file # Validate file
if not file.content_type.startswith("image/"): if not file.content_type.startswith("image/"):
raise HTTPException( raise HTTPException(
@@ -87,7 +118,7 @@ async def upload_avatar(
await db.commit() await db.commit()
await db.refresh(current_user) await db.refresh(current_user)
return UserPublic.model_validate(current_user) return UserPrivate.model_validate(current_user)
@router.post("/me/telegram", response_model=MessageResponse) @router.post("/me/telegram", response_model=MessageResponse)
@@ -165,8 +196,8 @@ async def get_my_stats(current_user: CurrentUser, db: DbSession):
@router.get("/{user_id}/stats", response_model=UserStats) @router.get("/{user_id}/stats", response_model=UserStats)
async def get_user_stats(user_id: int, db: DbSession): async def get_user_stats(user_id: int, db: DbSession, current_user: CurrentUser):
"""Получить статистику пользователя""" """Получить статистику пользователя. Requires authentication."""
result = await db.execute(select(User).where(User.id == user_id)) result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:
@@ -179,8 +210,8 @@ async def get_user_stats(user_id: int, db: DbSession):
@router.get("/{user_id}/profile", response_model=UserProfilePublic) @router.get("/{user_id}/profile", response_model=UserProfilePublic)
async def get_user_profile(user_id: int, db: DbSession): async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUser):
"""Получить публичный профиль пользователя со статистикой""" """Получить публичный профиль пользователя со статистикой. Requires authentication."""
result = await db.execute(select(User).where(User.id == user_id)) result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()

View File

@@ -22,12 +22,14 @@ class Settings(BaseSettings):
TELEGRAM_BOT_TOKEN: str = "" TELEGRAM_BOT_TOKEN: str = ""
TELEGRAM_BOT_USERNAME: str = "" TELEGRAM_BOT_USERNAME: str = ""
TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES: int = 10 TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES: int = 10
BOT_API_SECRET: str = "" # Secret key for bot-to-backend communication
# Frontend # Frontend
FRONTEND_URL: str = "http://localhost:3000" FRONTEND_URL: str = "http://localhost:3000"
# Uploads # Uploads
UPLOAD_DIR: str = "uploads" UPLOAD_DIR: str = "uploads"
MAX_UPLOAD_SIZE: int = 5 * 1024 * 1024 # 5 MB for avatars
MAX_IMAGE_SIZE: int = 15 * 1024 * 1024 # 15 MB MAX_IMAGE_SIZE: int = 15 * 1024 * 1024 # 15 MB
MAX_VIDEO_SIZE: int = 30 * 1024 * 1024 # 30 MB MAX_VIDEO_SIZE: int = 30 * 1024 * 1024 # 30 MB
ALLOWED_IMAGE_EXTENSIONS: set = {"jpg", "jpeg", "png", "gif", "webp"} ALLOWED_IMAGE_EXTENSIONS: set = {"jpg", "jpeg", "png", "gif", "webp"}

View File

@@ -0,0 +1,5 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
# Rate limiter using client IP address as key
limiter = Limiter(key_func=get_remote_address)

View File

@@ -1,7 +1,10 @@
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@@ -14,6 +17,7 @@ from pathlib import Path
from app.core.config import settings from app.core.config import settings
from app.core.database import engine, Base, async_session_maker from app.core.database import engine, Base, async_session_maker
from app.core.rate_limit import limiter
from app.api.v1 import router as api_router from app.api.v1 import router as api_router
from app.services.event_scheduler import event_scheduler from app.services.event_scheduler import event_scheduler
from app.services.dispute_scheduler import dispute_scheduler from app.services.dispute_scheduler import dispute_scheduler
@@ -49,6 +53,10 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
# Rate limiting
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
# CORS # CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,

View File

@@ -8,6 +8,9 @@ from app.models.activity import Activity, ActivityType
from app.models.event import Event, EventType from app.models.event import Event, EventType
from app.models.swap_request import SwapRequest, SwapRequestStatus from app.models.swap_request import SwapRequest, SwapRequestStatus
from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVote from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVote
from app.models.admin_log import AdminLog, AdminActionType
from app.models.admin_2fa import Admin2FASession
from app.models.static_content import StaticContent
__all__ = [ __all__ = [
"User", "User",
@@ -35,4 +38,8 @@ __all__ = [
"DisputeStatus", "DisputeStatus",
"DisputeComment", "DisputeComment",
"DisputeVote", "DisputeVote",
"AdminLog",
"AdminActionType",
"Admin2FASession",
"StaticContent",
] ]

View File

@@ -0,0 +1,20 @@
from datetime import datetime
from sqlalchemy import String, DateTime, Integer, ForeignKey, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class Admin2FASession(Base):
__tablename__ = "admin_2fa_sessions"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
code: Mapped[str] = mapped_column(String(6), nullable=False)
telegram_sent: Mapped[bool] = mapped_column(Boolean, default=False)
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
user: Mapped["User"] = relationship("User", foreign_keys=[user_id])

View File

@@ -0,0 +1,46 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, DateTime, Integer, ForeignKey, JSON
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class AdminActionType(str, Enum):
# User actions
USER_BAN = "user_ban"
USER_UNBAN = "user_unban"
USER_AUTO_UNBAN = "user_auto_unban" # System automatic unban
USER_ROLE_CHANGE = "user_role_change"
# Marathon actions
MARATHON_FORCE_FINISH = "marathon_force_finish"
MARATHON_DELETE = "marathon_delete"
# Content actions
CONTENT_UPDATE = "content_update"
# Broadcast actions
BROADCAST_ALL = "broadcast_all"
BROADCAST_MARATHON = "broadcast_marathon"
# Auth actions
ADMIN_LOGIN = "admin_login"
ADMIN_2FA_SUCCESS = "admin_2fa_success"
ADMIN_2FA_FAIL = "admin_2fa_fail"
class AdminLog(Base):
__tablename__ = "admin_logs"
id: Mapped[int] = mapped_column(primary_key=True)
admin_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True, index=True) # Nullable for system actions
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
target_type: Mapped[str] = mapped_column(String(50), nullable=False)
target_id: Mapped[int] = mapped_column(Integer, nullable=False)
details: Mapped[dict | None] = mapped_column(JSON, nullable=True)
ip_address: Mapped[str | None] = mapped_column(String(50), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
# Relationships
admin: Mapped["User"] = relationship("User", foreign_keys=[admin_id])

View File

@@ -29,6 +29,12 @@ class ProofType(str, Enum):
STEAM = "steam" STEAM = "steam"
class ChallengeStatus(str, Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
class Challenge(Base): class Challenge(Base):
__tablename__ = "challenges" __tablename__ = "challenges"
@@ -45,8 +51,13 @@ class Challenge(Base):
is_generated: Mapped[bool] = mapped_column(Boolean, default=True) is_generated: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Proposed challenges support
proposed_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
status: Mapped[str] = mapped_column(String(20), default="approved") # pending, approved, rejected
# Relationships # Relationships
game: Mapped["Game"] = relationship("Game", back_populates="challenges") game: Mapped["Game"] = relationship("Game", back_populates="challenges")
proposed_by: Mapped["User"] = relationship("User", foreign_keys=[proposed_by_id])
assignments: Mapped[list["Assignment"]] = relationship( assignments: Mapped[list["Assignment"]] = relationship(
"Assignment", "Assignment",
back_populates="challenge" back_populates="challenge"

View File

@@ -0,0 +1,20 @@
from datetime import datetime
from sqlalchemy import String, DateTime, Integer, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class StaticContent(Base):
__tablename__ = "static_content"
id: Mapped[int] = mapped_column(primary_key=True)
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
updated_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
updated_by: Mapped["User | None"] = relationship("User", foreign_keys=[updated_by_id])

View File

@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from sqlalchemy import String, BigInteger, DateTime from sqlalchemy import String, BigInteger, DateTime, Boolean, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base from app.core.database import Base
@@ -27,6 +27,13 @@ class User(Base):
role: Mapped[str] = mapped_column(String(20), default=UserRole.USER.value) role: Mapped[str] = mapped_column(String(20), default=UserRole.USER.value)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Ban fields
is_banned: Mapped[bool] = mapped_column(Boolean, default=False)
banned_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
banned_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # None = permanent
banned_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
ban_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Relationships # Relationships
created_marathons: Mapped[list["Marathon"]] = relationship( created_marathons: Mapped[list["Marathon"]] = relationship(
"Marathon", "Marathon",
@@ -47,6 +54,11 @@ class User(Base):
back_populates="approved_by", back_populates="approved_by",
foreign_keys="Game.approved_by_id" foreign_keys="Game.approved_by_id"
) )
banned_by: Mapped["User | None"] = relationship(
"User",
remote_side="User.id",
foreign_keys=[banned_by_id]
)
@property @property
def is_admin(self) -> bool: def is_admin(self) -> bool:

View File

@@ -3,7 +3,7 @@ from app.schemas.user import (
UserLogin, UserLogin,
UserUpdate, UserUpdate,
UserPublic, UserPublic,
UserWithTelegram, UserPrivate,
TokenResponse, TokenResponse,
TelegramLink, TelegramLink,
PasswordChange, PasswordChange,
@@ -37,6 +37,7 @@ from app.schemas.challenge import (
ChallengesPreviewResponse, ChallengesPreviewResponse,
ChallengeSaveItem, ChallengeSaveItem,
ChallengesSaveRequest, ChallengesSaveRequest,
ChallengesGenerateRequest,
) )
from app.schemas.assignment import ( from app.schemas.assignment import (
CompleteAssignment, CompleteAssignment,
@@ -80,6 +81,22 @@ from app.schemas.dispute import (
AssignmentDetailResponse, AssignmentDetailResponse,
ReturnedAssignmentResponse, ReturnedAssignmentResponse,
) )
from app.schemas.admin import (
BanUserRequest,
AdminUserResponse,
AdminLogResponse,
AdminLogsListResponse,
BroadcastRequest,
BroadcastResponse,
StaticContentResponse,
StaticContentUpdate,
StaticContentCreate,
TwoFactorInitiateRequest,
TwoFactorInitiateResponse,
TwoFactorVerifyRequest,
LoginResponse,
DashboardStats,
)
__all__ = [ __all__ = [
# User # User
@@ -87,7 +104,7 @@ __all__ = [
"UserLogin", "UserLogin",
"UserUpdate", "UserUpdate",
"UserPublic", "UserPublic",
"UserWithTelegram", "UserPrivate",
"TokenResponse", "TokenResponse",
"TelegramLink", "TelegramLink",
"PasswordChange", "PasswordChange",
@@ -118,6 +135,7 @@ __all__ = [
"ChallengesPreviewResponse", "ChallengesPreviewResponse",
"ChallengeSaveItem", "ChallengeSaveItem",
"ChallengesSaveRequest", "ChallengesSaveRequest",
"ChallengesGenerateRequest",
# Assignment # Assignment
"CompleteAssignment", "CompleteAssignment",
"AssignmentResponse", "AssignmentResponse",
@@ -155,4 +173,19 @@ __all__ = [
"DisputeResponse", "DisputeResponse",
"AssignmentDetailResponse", "AssignmentDetailResponse",
"ReturnedAssignmentResponse", "ReturnedAssignmentResponse",
# Admin
"BanUserRequest",
"AdminUserResponse",
"AdminLogResponse",
"AdminLogsListResponse",
"BroadcastRequest",
"BroadcastResponse",
"StaticContentResponse",
"StaticContentUpdate",
"StaticContentCreate",
"TwoFactorInitiateRequest",
"TwoFactorInitiateResponse",
"TwoFactorVerifyRequest",
"LoginResponse",
"DashboardStats",
] ]

View File

@@ -0,0 +1,119 @@
from datetime import datetime
from pydantic import BaseModel, Field
from typing import Any
# ============ User Ban ============
class BanUserRequest(BaseModel):
reason: str = Field(..., min_length=1, max_length=500)
banned_until: datetime | None = None # None = permanent ban
class AdminUserResponse(BaseModel):
id: int
login: str
nickname: str
role: str
avatar_url: str | None = None
telegram_id: int | None = None
telegram_username: str | None = None
marathons_count: int = 0
created_at: str
is_banned: bool = False
banned_at: str | None = None
banned_until: str | None = None # None = permanent
ban_reason: str | None = None
class Config:
from_attributes = True
# ============ Admin Logs ============
class AdminLogResponse(BaseModel):
id: int
admin_id: int | None = None # Nullable for system actions
admin_nickname: str | None = None # Nullable for system actions
action: str
target_type: str
target_id: int
details: dict | None = None
ip_address: str | None = None
created_at: datetime
class Config:
from_attributes = True
class AdminLogsListResponse(BaseModel):
logs: list[AdminLogResponse]
total: int
# ============ Broadcast ============
class BroadcastRequest(BaseModel):
message: str = Field(..., min_length=1, max_length=2000)
class BroadcastResponse(BaseModel):
sent_count: int
total_count: int
# ============ Static Content ============
class StaticContentResponse(BaseModel):
id: int
key: str
title: str
content: str
updated_at: datetime
created_at: datetime
class Config:
from_attributes = True
class StaticContentUpdate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
content: str = Field(..., min_length=1)
class StaticContentCreate(BaseModel):
key: str = Field(..., min_length=1, max_length=100, pattern=r"^[a-z0-9_-]+$")
title: str = Field(..., min_length=1, max_length=200)
content: str = Field(..., min_length=1)
# ============ 2FA ============
class TwoFactorInitiateRequest(BaseModel):
pass # No additional data needed
class TwoFactorInitiateResponse(BaseModel):
session_id: int
expires_at: datetime
message: str = "Code sent to Telegram"
class TwoFactorVerifyRequest(BaseModel):
session_id: int
code: str = Field(..., min_length=6, max_length=6)
class LoginResponse(BaseModel):
"""Login response that may require 2FA"""
access_token: str | None = None
token_type: str = "bearer"
user: Any = None # UserPrivate
requires_2fa: bool = False
two_factor_session_id: int | None = None
# ============ Dashboard Stats ============
class DashboardStats(BaseModel):
users_count: int
banned_users_count: int
marathons_count: int
active_marathons_count: int
games_count: int
total_participations: int
recent_logs: list[AdminLogResponse] = []

View File

@@ -1,10 +1,19 @@
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.models.challenge import ChallengeType, Difficulty, ProofType from app.models.challenge import ChallengeType, Difficulty, ProofType, ChallengeStatus
from app.schemas.game import GameShort from app.schemas.game import GameShort
class ProposedByUser(BaseModel):
"""Minimal user info for proposed challenges"""
id: int
nickname: str
class Config:
from_attributes = True
class ChallengeBase(BaseModel): class ChallengeBase(BaseModel):
title: str = Field(..., min_length=1, max_length=100) title: str = Field(..., min_length=1, max_length=100)
description: str = Field(..., min_length=1) description: str = Field(..., min_length=1)
@@ -36,11 +45,18 @@ class ChallengeResponse(ChallengeBase):
game: GameShort game: GameShort
is_generated: bool is_generated: bool
created_at: datetime created_at: datetime
status: str = "approved"
proposed_by: ProposedByUser | None = None
class Config: class Config:
from_attributes = True from_attributes = True
class ChallengePropose(ChallengeBase):
"""Schema for proposing a challenge by a participant"""
pass
class ChallengeGenerated(BaseModel): class ChallengeGenerated(BaseModel):
"""Schema for GPT-generated challenges""" """Schema for GPT-generated challenges"""
title: str title: str
@@ -88,3 +104,8 @@ class ChallengeSaveItem(BaseModel):
class ChallengesSaveRequest(BaseModel): class ChallengesSaveRequest(BaseModel):
"""Request to save previewed challenges""" """Request to save previewed challenges"""
challenges: list[ChallengeSaveItem] challenges: list[ChallengeSaveItem]
class ChallengesGenerateRequest(BaseModel):
"""Request to generate challenges for specific games"""
game_ids: list[int] | None = None # If None, generate for all approved games without challenges

View File

@@ -29,30 +29,30 @@ class UserUpdate(BaseModel):
class UserPublic(UserBase): class UserPublic(UserBase):
"""Public user info visible to other users - minimal data"""
id: int id: int
login: str
avatar_url: str | None = None avatar_url: str | None = None
role: str = "user" role: str = "user"
telegram_id: int | None = None telegram_avatar_url: str | None = None # Only TG avatar is public
telegram_username: str | None = None
telegram_first_name: str | None = None
telegram_last_name: str | None = None
telegram_avatar_url: str | None = None
created_at: datetime created_at: datetime
class Config: class Config:
from_attributes = True from_attributes = True
class UserWithTelegram(UserPublic): class UserPrivate(UserPublic):
"""Full user info visible only to the user themselves"""
login: str
telegram_id: int | None = None telegram_id: int | None = None
telegram_username: str | None = None telegram_username: str | None = None
telegram_first_name: str | None = None
telegram_last_name: str | None = None
class TokenResponse(BaseModel): class TokenResponse(BaseModel):
access_token: str access_token: str
token_type: str = "bearer" token_type: str = "bearer"
user: UserPublic user: UserPrivate
class TelegramLink(BaseModel): class TelegramLink(BaseModel):

View File

@@ -36,7 +36,10 @@ class GPTService:
{games_text} {games_text}
ВАЖНО: Челленджи должны быть СПЕЦИФИЧНЫМИ для каждой игры! ВАЖНО:
- ВСЕ ТЕКСТЫ (title, description, proof_hint) ОБЯЗАТЕЛЬНО ПИШИ НА РУССКОМ ЯЗЫКЕ!
- Используй интернет для поиска актуальной информации об играх
- Челленджи должны быть СПЕЦИФИЧНЫМИ для каждой игры!
- Используй РЕАЛЬНЫЕ названия локаций, боссов, персонажей, миссий, уровней из игры - Используй РЕАЛЬНЫЕ названия локаций, боссов, персонажей, миссий, уровней из игры
- Основывайся на том, какие челленджи РЕАЛЬНО делают игроки в этой игре - Основывайся на том, какие челленджи РЕАЛЬНО делают игроки в этой игре
- НЕ генерируй абстрактные челленджи типа "пройди уровень" или "убей 10 врагов" - НЕ генерируй абстрактные челленджи типа "пройди уровень" или "убей 10 врагов"
@@ -44,7 +47,7 @@ class GPTService:
Требования по сложности ДЛЯ КАЖДОЙ ИГРЫ: Требования по сложности ДЛЯ КАЖДОЙ ИГРЫ:
- 2 лёгких (15-30 мин): простые задачи - 2 лёгких (15-30 мин): простые задачи
- 2 средних (1-2 часа): требуют навыка - 2 средних (1-2 часа): требуют навыка
- 2 сложных (3+ часа): серьёзный челлендж - 2 сложных (3-12 часов): серьёзный челлендж
Формат ответа — JSON с объектом где ключи это ТОЧНЫЕ названия игр, как они указаны в запросе: Формат ответа — JSON с объектом где ключи это ТОЧНЫЕ названия игр, как они указаны в запросе:
{{ {{
@@ -59,10 +62,10 @@ class GPTService:
}} }}
points: easy=20-40, medium=45-75, hard=90-150 points: easy=20-40, medium=45-75, hard=90-150
Ответь ТОЛЬКО JSON.""" Ответь ТОЛЬКО JSON. ОПИСАНИЕ И НАЗВАНИЕ ЧЕЛЛЕНДЖА ТОЛЬКО НА РУССКОМ ЯЗЫКЕ!"""
response = await self.client.chat.completions.create( response = await self.client.chat.completions.create(
model="gpt-5-mini", model="gpt-5",
messages=[{"role": "user", "content": prompt}], messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"}, response_format={"type": "json_object"},
) )

View File

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

View File

@@ -31,5 +31,8 @@ python-magic==0.4.27
# S3 Storage # S3 Storage
boto3==1.34.0 boto3==1.34.0
# Rate limiting
slowapi==0.1.9
# Utils # Utils
python-dotenv==1.0.0 python-dotenv==1.0.0

30
backup-service/Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM python:3.11-slim
WORKDIR /app
# Install PostgreSQL client (for pg_dump and psql) and cron
RUN apt-get update && apt-get install -y \
postgresql-client \
cron \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Make scripts executable
RUN chmod +x backup.py restore.py
# Setup cron
COPY crontab /etc/cron.d/backup-cron
RUN chmod 0644 /etc/cron.d/backup-cron
RUN crontab /etc/cron.d/backup-cron
# Create log file
RUN touch /var/log/cron.log
# Start cron in foreground and tail logs
CMD ["sh", "-c", "printenv > /etc/environment && cron && tail -f /var/log/cron.log"]

217
backup-service/backup.py Normal file
View File

@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""
PostgreSQL Backup Service for WebApp.
- Creates pg_dump backup
- Compresses with gzip
- Uploads to S3 FirstVDS
- Rotates old backups (configurable retention)
- Sends Telegram notifications
"""
import gzip
import os
import subprocess
import sys
from datetime import datetime, timedelta, timezone
import boto3
import httpx
from botocore.config import Config as BotoConfig
from botocore.exceptions import ClientError
from config import config
def create_s3_client():
"""Initialize S3 client (same pattern as backend storage.py)."""
return boto3.client(
"s3",
endpoint_url=config.S3_ENDPOINT_URL,
aws_access_key_id=config.S3_ACCESS_KEY_ID,
aws_secret_access_key=config.S3_SECRET_ACCESS_KEY,
region_name=config.S3_REGION or "us-east-1",
config=BotoConfig(signature_version="s3v4"),
)
def send_telegram_notification(message: str, is_error: bool = False) -> None:
"""Send notification to Telegram admin."""
if not config.TELEGRAM_BOT_TOKEN or not config.TELEGRAM_ADMIN_ID:
print("Telegram not configured, skipping notification")
return
emoji = "\u274c" if is_error else "\u2705"
text = f"{emoji} *Database Backup*\n\n{message}"
url = f"https://api.telegram.org/bot{config.TELEGRAM_BOT_TOKEN}/sendMessage"
data = {
"chat_id": config.TELEGRAM_ADMIN_ID,
"text": text,
"parse_mode": "Markdown",
}
try:
response = httpx.post(url, json=data, timeout=30)
response.raise_for_status()
print("Telegram notification sent")
except Exception as e:
print(f"Failed to send Telegram notification: {e}")
def create_backup() -> tuple[str, bytes]:
"""Create pg_dump backup and compress it."""
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
filename = f"marathon_backup_{timestamp}.sql.gz"
# Build pg_dump command
env = os.environ.copy()
env["PGPASSWORD"] = config.DB_PASSWORD
cmd = [
"pg_dump",
"-h",
config.DB_HOST,
"-p",
config.DB_PORT,
"-U",
config.DB_USER,
"-d",
config.DB_NAME,
"--no-owner",
"--no-acl",
"-F",
"p", # plain SQL format
]
print(f"Running pg_dump for database {config.DB_NAME}...")
result = subprocess.run(
cmd,
env=env,
capture_output=True,
)
if result.returncode != 0:
raise Exception(f"pg_dump failed: {result.stderr.decode()}")
# Compress the output
print("Compressing backup...")
compressed = gzip.compress(result.stdout, compresslevel=9)
return filename, compressed
def upload_to_s3(s3_client, filename: str, data: bytes) -> str:
"""Upload backup to S3."""
key = f"{config.S3_BACKUP_PREFIX}{filename}"
print(f"Uploading to S3: {key}...")
s3_client.put_object(
Bucket=config.S3_BUCKET_NAME,
Key=key,
Body=data,
ContentType="application/gzip",
)
return key
def rotate_old_backups(s3_client) -> int:
"""Delete backups older than BACKUP_RETENTION_DAYS."""
cutoff_date = datetime.now(timezone.utc) - timedelta(
days=config.BACKUP_RETENTION_DAYS
)
deleted_count = 0
print(f"Rotating backups older than {config.BACKUP_RETENTION_DAYS} days...")
# List all objects with backup prefix
try:
paginator = s3_client.get_paginator("list_objects_v2")
pages = paginator.paginate(
Bucket=config.S3_BUCKET_NAME,
Prefix=config.S3_BACKUP_PREFIX,
)
for page in pages:
for obj in page.get("Contents", []):
last_modified = obj["LastModified"]
if last_modified.tzinfo is None:
last_modified = last_modified.replace(tzinfo=timezone.utc)
if last_modified < cutoff_date:
s3_client.delete_object(
Bucket=config.S3_BUCKET_NAME,
Key=obj["Key"],
)
deleted_count += 1
print(f"Deleted old backup: {obj['Key']}")
except ClientError as e:
print(f"Error during rotation: {e}")
return deleted_count
def main() -> int:
"""Main backup routine."""
start_time = datetime.now()
print(f"{'=' * 50}")
print(f"Backup started at {start_time}")
print(f"{'=' * 50}")
try:
# Validate configuration
if not config.S3_BUCKET_NAME:
raise Exception("S3_BUCKET_NAME is not configured")
if not config.S3_ACCESS_KEY_ID:
raise Exception("S3_ACCESS_KEY_ID is not configured")
if not config.S3_SECRET_ACCESS_KEY:
raise Exception("S3_SECRET_ACCESS_KEY is not configured")
if not config.S3_ENDPOINT_URL:
raise Exception("S3_ENDPOINT_URL is not configured")
# Create S3 client
s3_client = create_s3_client()
# Create backup
filename, data = create_backup()
size_mb = len(data) / (1024 * 1024)
print(f"Backup created: {filename} ({size_mb:.2f} MB)")
# Upload to S3
s3_key = upload_to_s3(s3_client, filename, data)
print(f"Uploaded to S3: {s3_key}")
# Rotate old backups
deleted_count = rotate_old_backups(s3_client)
print(f"Deleted {deleted_count} old backups")
# Calculate duration
duration = datetime.now() - start_time
# Send success notification
message = (
f"Backup completed successfully!\n\n"
f"*File:* `{filename}`\n"
f"*Size:* {size_mb:.2f} MB\n"
f"*Duration:* {duration.seconds}s\n"
f"*Deleted old:* {deleted_count} files"
)
send_telegram_notification(message, is_error=False)
print(f"{'=' * 50}")
print("Backup completed successfully!")
print(f"{'=' * 50}")
return 0
except Exception as e:
error_msg = f"Backup failed!\n\n*Error:* `{str(e)}`"
send_telegram_notification(error_msg, is_error=True)
print(f"{'=' * 50}")
print(f"Backup failed: {e}")
print(f"{'=' * 50}")
return 1
if __name__ == "__main__":
sys.exit(main())

33
backup-service/config.py Normal file
View File

@@ -0,0 +1,33 @@
"""Configuration for backup service."""
import os
from dataclasses import dataclass
@dataclass
class Config:
"""Backup service configuration from environment variables."""
# Database
DB_HOST: str = os.getenv("DB_HOST", "db")
DB_PORT: str = os.getenv("DB_PORT", "5432")
DB_NAME: str = os.getenv("DB_NAME", "marathon")
DB_USER: str = os.getenv("DB_USER", "marathon")
DB_PASSWORD: str = os.getenv("DB_PASSWORD", "123")
# S3
S3_BUCKET_NAME: str = os.getenv("S3_BUCKET_NAME", "")
S3_REGION: str = os.getenv("S3_REGION", "ru-1")
S3_ACCESS_KEY_ID: str = os.getenv("S3_ACCESS_KEY_ID", "")
S3_SECRET_ACCESS_KEY: str = os.getenv("S3_SECRET_ACCESS_KEY", "")
S3_ENDPOINT_URL: str = os.getenv("S3_ENDPOINT_URL", "")
S3_BACKUP_PREFIX: str = os.getenv("S3_BACKUP_PREFIX", "backups/")
# Telegram
TELEGRAM_BOT_TOKEN: str = os.getenv("TELEGRAM_BOT_TOKEN", "")
TELEGRAM_ADMIN_ID: str = os.getenv("TELEGRAM_ADMIN_ID", "947392854")
# Backup settings
BACKUP_RETENTION_DAYS: int = int(os.getenv("BACKUP_RETENTION_DAYS", "14"))
config = Config()

4
backup-service/crontab Normal file
View File

@@ -0,0 +1,4 @@
# Backup cron job
# Run backup daily at 3:00 AM UTC
0 3 * * * /usr/local/bin/python /app/backup.py >> /var/log/cron.log 2>&1
# Empty line required at end of crontab

View File

@@ -0,0 +1,2 @@
boto3==1.34.0
httpx==0.26.0

158
backup-service/restore.py Normal file
View File

@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""
Restore PostgreSQL database from S3 backup.
Usage:
python restore.py - List available backups
python restore.py <filename> - Restore from specific backup
"""
import gzip
import os
import subprocess
import sys
import boto3
from botocore.config import Config as BotoConfig
from botocore.exceptions import ClientError
from config import config
def create_s3_client():
"""Initialize S3 client."""
return boto3.client(
"s3",
endpoint_url=config.S3_ENDPOINT_URL,
aws_access_key_id=config.S3_ACCESS_KEY_ID,
aws_secret_access_key=config.S3_SECRET_ACCESS_KEY,
region_name=config.S3_REGION or "us-east-1",
config=BotoConfig(signature_version="s3v4"),
)
def list_backups(s3_client) -> list[tuple[str, float, str]]:
"""List all available backups."""
print("Available backups:\n")
try:
paginator = s3_client.get_paginator("list_objects_v2")
pages = paginator.paginate(
Bucket=config.S3_BUCKET_NAME,
Prefix=config.S3_BACKUP_PREFIX,
)
backups = []
for page in pages:
for obj in page.get("Contents", []):
filename = obj["Key"].replace(config.S3_BACKUP_PREFIX, "")
size_mb = obj["Size"] / (1024 * 1024)
modified = obj["LastModified"].strftime("%Y-%m-%d %H:%M:%S")
backups.append((filename, size_mb, modified))
# Sort by date descending (newest first)
backups.sort(key=lambda x: x[2], reverse=True)
for filename, size_mb, modified in backups:
print(f" {filename} ({size_mb:.2f} MB) - {modified}")
return backups
except ClientError as e:
print(f"Error listing backups: {e}")
return []
def restore_backup(s3_client, filename: str) -> None:
"""Download and restore backup."""
key = f"{config.S3_BACKUP_PREFIX}{filename}"
print(f"Downloading {filename} from S3...")
try:
response = s3_client.get_object(
Bucket=config.S3_BUCKET_NAME,
Key=key,
)
compressed_data = response["Body"].read()
except ClientError as e:
raise Exception(f"Failed to download backup: {e}")
print("Decompressing...")
sql_data = gzip.decompress(compressed_data)
print(f"Restoring to database {config.DB_NAME}...")
# Build psql command
env = os.environ.copy()
env["PGPASSWORD"] = config.DB_PASSWORD
cmd = [
"psql",
"-h",
config.DB_HOST,
"-p",
config.DB_PORT,
"-U",
config.DB_USER,
"-d",
config.DB_NAME,
]
result = subprocess.run(
cmd,
env=env,
input=sql_data,
capture_output=True,
)
if result.returncode != 0:
stderr = result.stderr.decode()
# psql may return warnings that aren't fatal errors
if "ERROR" in stderr:
raise Exception(f"psql restore failed: {stderr}")
else:
print(f"Warnings: {stderr}")
print("Restore completed successfully!")
def main() -> int:
"""Main restore routine."""
# Validate configuration
if not config.S3_BUCKET_NAME:
print("Error: S3_BUCKET_NAME is not configured")
return 1
s3_client = create_s3_client()
if len(sys.argv) < 2:
# List available backups
backups = list_backups(s3_client)
if backups:
print(f"\nTo restore, run: python restore.py <filename>")
else:
print("No backups found.")
return 0
filename = sys.argv[1]
# Confirm restore
print(f"WARNING: This will restore database from {filename}")
print("This may overwrite existing data!")
print()
confirm = input("Type 'yes' to continue: ")
if confirm.lower() != "yes":
print("Restore cancelled.")
return 0
try:
restore_backup(s3_client, filename)
return 0
except Exception as e:
print(f"Restore failed: {e}")
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -5,6 +5,7 @@ class Settings(BaseSettings):
TELEGRAM_BOT_TOKEN: str TELEGRAM_BOT_TOKEN: str
API_URL: str = "http://backend:8000" API_URL: str = "http://backend:8000"
BOT_USERNAME: str = "" # Will be set dynamically on startup BOT_USERNAME: str = "" # Will be set dynamically on startup
BOT_API_SECRET: str = "" # Secret for backend API communication
class Config: class Config:
env_file = ".env" env_file = ".env"

View File

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

View File

@@ -32,6 +32,11 @@ class APIClient:
session = await self._get_session() session = await self._get_session()
url = f"{self.base_url}/api/v1{endpoint}" url = f"{self.base_url}/api/v1{endpoint}"
# Add bot secret header for authentication
headers = kwargs.pop("headers", {})
if settings.BOT_API_SECRET:
headers["X-Bot-Secret"] = settings.BOT_API_SECRET
logger.info(f"[APIClient] {method} {url}") logger.info(f"[APIClient] {method} {url}")
if 'json' in kwargs: if 'json' in kwargs:
logger.info(f"[APIClient] Request body: {kwargs['json']}") logger.info(f"[APIClient] Request body: {kwargs['json']}")
@@ -39,7 +44,7 @@ class APIClient:
logger.info(f"[APIClient] Request params: {kwargs['params']}") logger.info(f"[APIClient] Request params: {kwargs['params']}")
try: try:
async with session.request(method, url, **kwargs) as response: async with session.request(method, url, headers=headers, **kwargs) as response:
logger.info(f"[APIClient] Response status: {response.status}") logger.info(f"[APIClient] Response status: {response.status}")
response_text = await response.text() response_text = await response.text()
logger.info(f"[APIClient] Response body: {response_text[:500]}") logger.info(f"[APIClient] Response body: {response_text[:500]}")

View File

@@ -27,7 +27,8 @@ services:
SECRET_KEY: ${SECRET_KEY:-change-me-in-production} SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
OPENAI_API_KEY: ${OPENAI_API_KEY} OPENAI_API_KEY: ${OPENAI_API_KEY}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-GameMarathonBot} TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-BCMarathonbot}
BOT_API_SECRET: ${BOT_API_SECRET:-}
DEBUG: ${DEBUG:-false} DEBUG: ${DEBUG:-false}
# S3 Storage # S3 Storage
S3_ENABLED: ${S3_ENABLED:-false} S3_ENABLED: ${S3_ENABLED:-false}
@@ -81,9 +82,60 @@ services:
environment: environment:
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
- API_URL=http://backend:8000 - API_URL=http://backend:8000
- BOT_API_SECRET=${BOT_API_SECRET:-}
depends_on: depends_on:
- backend - backend
restart: unless-stopped restart: unless-stopped
status:
build:
context: ./status-service
dockerfile: Dockerfile
container_name: marathon-status
environment:
BACKEND_URL: http://backend:8000
FRONTEND_URL: http://frontend:80
BOT_URL: http://bot:8080
EXTERNAL_URL: ${EXTERNAL_URL:-}
PUBLIC_URL: ${PUBLIC_URL:-}
CHECK_INTERVAL: "30"
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
TELEGRAM_ADMIN_ID: ${TELEGRAM_ADMIN_ID:-947392854}
volumes:
- status_data:/app/data
ports:
- "8001:8001"
depends_on:
- backend
- frontend
- bot
restart: unless-stopped
backup:
build:
context: ./backup-service
dockerfile: Dockerfile
container_name: marathon-backup
environment:
DB_HOST: db
DB_PORT: "5432"
DB_NAME: marathon
DB_USER: marathon
DB_PASSWORD: ${DB_PASSWORD:-marathon}
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-}
S3_REGION: ${S3_REGION:-ru-1}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-}
S3_BACKUP_PREFIX: ${S3_BACKUP_PREFIX:-backups/}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
TELEGRAM_ADMIN_ID: ${TELEGRAM_ADMIN_ID:-947392854}
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-14}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
volumes: volumes:
postgres_data: postgres_data:
status_data:

View File

@@ -1,6 +1,7 @@
import { Routes, Route, Navigate } from 'react-router-dom' import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { ToastContainer, ConfirmModal } from '@/components/ui' import { ToastContainer, ConfirmModal } from '@/components/ui'
import { BannedScreen } from '@/components/BannedScreen'
// Layout // Layout
import { Layout } from '@/components/layout/Layout' import { Layout } from '@/components/layout/Layout'
@@ -20,6 +21,19 @@ import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage'
import { ProfilePage } from '@/pages/ProfilePage' import { ProfilePage } from '@/pages/ProfilePage'
import { UserProfilePage } from '@/pages/UserProfilePage' import { UserProfilePage } from '@/pages/UserProfilePage'
import { NotFoundPage } from '@/pages/NotFoundPage' import { NotFoundPage } from '@/pages/NotFoundPage'
import { TeapotPage } from '@/pages/TeapotPage'
import { ServerErrorPage } from '@/pages/ServerErrorPage'
// Admin Pages
import {
AdminLayout,
AdminDashboardPage,
AdminUsersPage,
AdminMarathonsPage,
AdminLogsPage,
AdminBroadcastPage,
AdminContentPage,
} from '@/pages/admin'
// Protected route wrapper // Protected route wrapper
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -44,6 +58,19 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
} }
function App() { function App() {
const banInfo = useAuthStore((state) => state.banInfo)
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
// Show banned screen if user is authenticated and banned
if (isAuthenticated && banInfo) {
return (
<>
<ToastContainer />
<BannedScreen banInfo={banInfo} />
</>
)
}
return ( return (
<> <>
<ToastContainer /> <ToastContainer />
@@ -148,6 +175,32 @@ function App() {
<Route path="users/:id" element={<UserProfilePage />} /> <Route path="users/:id" element={<UserProfilePage />} />
{/* Easter egg - 418 I'm a teapot */}
<Route path="418" element={<TeapotPage />} />
<Route path="teapot" element={<TeapotPage />} />
<Route path="tea" element={<TeapotPage />} />
{/* Server error page */}
<Route path="500" element={<ServerErrorPage />} />
<Route path="error" element={<ServerErrorPage />} />
{/* Admin routes */}
<Route
path="admin"
element={
<ProtectedRoute>
<AdminLayout />
</ProtectedRoute>
}
>
<Route index element={<AdminDashboardPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="marathons" element={<AdminMarathonsPage />} />
<Route path="logs" element={<AdminLogsPage />} />
<Route path="broadcast" element={<AdminBroadcastPage />} />
<Route path="content" element={<AdminContentPage />} />
</Route>
{/* 404 - must be last */} {/* 404 - must be last */}
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Route> </Route>

View File

@@ -1,10 +1,25 @@
import client from './client' import client from './client'
import type { AdminUser, AdminMarathon, UserRole, PlatformStats } from '@/types' import type {
AdminUser,
AdminMarathon,
UserRole,
PlatformStats,
AdminLogsResponse,
BroadcastResponse,
StaticContent,
DashboardStats
} from '@/types'
export const adminApi = { export const adminApi = {
// Dashboard
getDashboard: async (): Promise<DashboardStats> => {
const response = await client.get<DashboardStats>('/admin/dashboard')
return response.data
},
// Users // Users
listUsers: async (skip = 0, limit = 50, search?: string): Promise<AdminUser[]> => { listUsers: async (skip = 0, limit = 50, search?: string, bannedOnly = false): Promise<AdminUser[]> => {
const params: Record<string, unknown> = { skip, limit } const params: Record<string, unknown> = { skip, limit, banned_only: bannedOnly }
if (search) params.search = search if (search) params.search = search
const response = await client.get<AdminUser[]>('/admin/users', { params }) const response = await client.get<AdminUser[]>('/admin/users', { params })
return response.data return response.data
@@ -24,6 +39,19 @@ export const adminApi = {
await client.delete(`/admin/users/${id}`) await client.delete(`/admin/users/${id}`)
}, },
banUser: async (id: number, reason: string, bannedUntil?: string): Promise<AdminUser> => {
const response = await client.post<AdminUser>(`/admin/users/${id}/ban`, {
reason,
banned_until: bannedUntil || null,
})
return response.data
},
unbanUser: async (id: number): Promise<AdminUser> => {
const response = await client.post<AdminUser>(`/admin/users/${id}/unban`)
return response.data
},
// Marathons // Marathons
listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => { listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => {
const params: Record<string, unknown> = { skip, limit } const params: Record<string, unknown> = { skip, limit }
@@ -36,9 +64,62 @@ export const adminApi = {
await client.delete(`/admin/marathons/${id}`) await client.delete(`/admin/marathons/${id}`)
}, },
forceFinishMarathon: async (id: number): Promise<void> => {
await client.post(`/admin/marathons/${id}/force-finish`)
},
// Stats // Stats
getStats: async (): Promise<PlatformStats> => { getStats: async (): Promise<PlatformStats> => {
const response = await client.get<PlatformStats>('/admin/stats') const response = await client.get<PlatformStats>('/admin/stats')
return response.data return response.data
}, },
// Logs
getLogs: async (skip = 0, limit = 50, action?: string, adminId?: number): Promise<AdminLogsResponse> => {
const params: Record<string, unknown> = { skip, limit }
if (action) params.action = action
if (adminId) params.admin_id = adminId
const response = await client.get<AdminLogsResponse>('/admin/logs', { params })
return response.data
},
// Broadcast
broadcastToAll: async (message: string): Promise<BroadcastResponse> => {
const response = await client.post<BroadcastResponse>('/admin/broadcast/all', { message })
return response.data
},
broadcastToMarathon: async (marathonId: number, message: string): Promise<BroadcastResponse> => {
const response = await client.post<BroadcastResponse>(`/admin/broadcast/marathon/${marathonId}`, { message })
return response.data
},
// Static Content
listContent: async (): Promise<StaticContent[]> => {
const response = await client.get<StaticContent[]>('/admin/content')
return response.data
},
getContent: async (key: string): Promise<StaticContent> => {
const response = await client.get<StaticContent>(`/admin/content/${key}`)
return response.data
},
updateContent: async (key: string, title: string, content: string): Promise<StaticContent> => {
const response = await client.put<StaticContent>(`/admin/content/${key}`, { title, content })
return response.data
},
createContent: async (key: string, title: string, content: string): Promise<StaticContent> => {
const response = await client.post<StaticContent>('/admin/content', { key, title, content })
return response.data
},
}
// Public content API (no auth required)
export const contentApi = {
getPublicContent: async (key: string): Promise<StaticContent> => {
const response = await client.get<StaticContent>(`/content/${key}`)
return response.data
},
} }

View File

@@ -1,5 +1,5 @@
import client from './client' import client from './client'
import type { TokenResponse, User } from '@/types' import type { TokenResponse, LoginResponse, User } from '@/types'
export interface RegisterData { export interface RegisterData {
login: string login: string
@@ -18,8 +18,15 @@ export const authApi = {
return response.data return response.data
}, },
login: async (data: LoginData): Promise<TokenResponse> => { login: async (data: LoginData): Promise<LoginResponse> => {
const response = await client.post<TokenResponse>('/auth/login', data) const response = await client.post<LoginResponse>('/auth/login', data)
return response.data
},
verify2FA: async (sessionId: number, code: string): Promise<TokenResponse> => {
const response = await client.post<TokenResponse>('/auth/2fa/verify', null, {
params: { session_id: sessionId, code }
})
return response.data return response.data
}, },

View File

@@ -1,4 +1,5 @@
import axios, { AxiosError } from 'axios' import axios, { AxiosError } from 'axios'
import { useAuthStore, type BanInfo } from '@/store/auth'
const API_URL = import.meta.env.VITE_API_URL || '/api/v1' const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
@@ -18,15 +19,51 @@ client.interceptors.request.use((config) => {
return config return config
}) })
// Helper to check if detail is ban info object
function isBanInfo(detail: unknown): detail is BanInfo {
return (
typeof detail === 'object' &&
detail !== null &&
'banned_at' in detail &&
'reason' in detail
)
}
// Response interceptor to handle errors // Response interceptor to handle errors
client.interceptors.response.use( client.interceptors.response.use(
(response) => response, (response) => response,
(error: AxiosError<{ detail: string }>) => { (error: AxiosError<{ detail: string | BanInfo }>) => {
// Unauthorized - redirect to login
if (error.response?.status === 401) { if (error.response?.status === 401) {
localStorage.removeItem('token') localStorage.removeItem('token')
localStorage.removeItem('user') localStorage.removeItem('user')
window.location.href = '/login' window.location.href = '/login'
} }
// Forbidden - check if user is banned
if (error.response?.status === 403) {
const detail = error.response.data?.detail
if (isBanInfo(detail)) {
// User is banned - set ban info in store
useAuthStore.getState().setBanned(detail)
}
}
// Server error or network error - redirect to 500 page
if (
error.response?.status === 500 ||
error.response?.status === 502 ||
error.response?.status === 503 ||
error.response?.status === 504 ||
error.code === 'ERR_NETWORK' ||
error.code === 'ECONNABORTED'
) {
// Only redirect if not already on error page
if (!window.location.pathname.startsWith('/500') && !window.location.pathname.startsWith('/error')) {
window.location.href = '/500'
}
}
return Promise.reject(error) return Promise.reject(error)
} }
) )

View File

@@ -79,8 +79,14 @@ export const gamesApi = {
await client.delete(`/challenges/${id}`) await client.delete(`/challenges/${id}`)
}, },
previewChallenges: async (marathonId: number): Promise<ChallengesPreviewResponse> => { updateChallenge: async (id: number, data: Partial<CreateChallengeData>): Promise<Challenge> => {
const response = await client.post<ChallengesPreviewResponse>(`/marathons/${marathonId}/preview-challenges`) const response = await client.patch<Challenge>(`/challenges/${id}`, data)
return response.data
},
previewChallenges: async (marathonId: number, gameIds?: number[]): Promise<ChallengesPreviewResponse> => {
const data = gameIds?.length ? { game_ids: gameIds } : undefined
const response = await client.post<ChallengesPreviewResponse>(`/marathons/${marathonId}/preview-challenges`, data)
return response.data return response.data
}, },
@@ -88,4 +94,30 @@ export const gamesApi = {
const response = await client.post<{ message: string }>(`/marathons/${marathonId}/save-challenges`, { challenges }) const response = await client.post<{ message: string }>(`/marathons/${marathonId}/save-challenges`, { challenges })
return response.data return response.data
}, },
// Proposed challenges
proposeChallenge: async (gameId: number, data: CreateChallengeData): Promise<Challenge> => {
const response = await client.post<Challenge>(`/games/${gameId}/propose-challenge`, data)
return response.data
},
getProposedChallenges: async (marathonId: number): Promise<Challenge[]> => {
const response = await client.get<Challenge[]>(`/marathons/${marathonId}/proposed-challenges`)
return response.data
},
getMyProposedChallenges: async (marathonId: number): Promise<Challenge[]> => {
const response = await client.get<Challenge[]>(`/marathons/${marathonId}/my-proposed-challenges`)
return response.data
},
approveChallenge: async (id: number): Promise<Challenge> => {
const response = await client.patch<Challenge>(`/challenges/${id}/approve`)
return response.data
},
rejectChallenge: async (id: number): Promise<Challenge> => {
const response = await client.patch<Challenge>(`/challenges/${id}/reject`)
return response.data
},
} }

View File

@@ -3,7 +3,7 @@ export { marathonsApi } from './marathons'
export { gamesApi } from './games' export { gamesApi } from './games'
export { wheelApi } from './wheel' export { wheelApi } from './wheel'
export { feedApi } from './feed' export { feedApi } from './feed'
export { adminApi } from './admin' export { adminApi, contentApi } from './admin'
export { eventsApi } from './events' export { eventsApi } from './events'
export { challengesApi } from './challenges' export { challengesApi } from './challenges'
export { assignmentsApi } from './assignments' export { assignmentsApi } from './assignments'

View File

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

View File

@@ -1,13 +1,13 @@
import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react' import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate, Link } from 'react-router-dom'
import { feedApi } from '@/api' import { feedApi } from '@/api'
import type { Activity, ActivityType } from '@/types' import type { Activity, ActivityType } from '@/types'
import { Loader2, ChevronDown, Bell, ExternalLink, AlertTriangle } from 'lucide-react' import { Loader2, ChevronDown, Activity as ActivityIcon, ExternalLink, AlertTriangle, Sparkles, Zap } from 'lucide-react'
import { UserAvatar } from '@/components/ui'
import { import {
formatRelativeTime, formatRelativeTime,
getActivityIcon, getActivityIcon,
getActivityColor, getActivityColor,
getActivityBgClass,
isEventActivity, isEventActivity,
formatActivityMessage, formatActivityMessage,
} from '@/utils/activity' } from '@/utils/activity'
@@ -99,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" />
@@ -167,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)
@@ -186,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>
@@ -208,46 +259,57 @@ function ActivityItem({ activity }: ActivityItemProps) {
} }
return ( return (
<div className={`px-4 py-3 hover:bg-gray-700/20 transition-colors ${bgClass}`}> <div className={`
px-5 py-4 border-l-2 transition-all duration-200
${accent.border} ${isNew ? accent.bg : ''}
hover:bg-dark-700/30 group
`}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{/* Avatar */} {/* Avatar */}
<div className="flex-shrink-0"> <Link to={`/users/${activity.user.id}`} className="flex-shrink-0 relative" onClick={(e) => e.stopPropagation()}>
{activity.user.avatar_url ? ( <UserAvatar
<img userId={activity.user.id}
src={activity.user.avatar_url} hasAvatar={!!activity.user.avatar_url}
alt={activity.user.nickname} nickname={activity.user.nickname}
className="w-8 h-8 rounded-full object-cover" size="sm"
/> />
) : ( {/* Activity type badge */}
<div className="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center"> <div className={`
<span className="text-xs text-gray-400 font-medium"> absolute -bottom-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center
{activity.user.nickname.charAt(0).toUpperCase()} border-2 border-dark-800
</span> ${activity.type === 'complete' ? 'bg-green-500' :
</div> activity.type === 'drop' ? 'bg-red-500' :
)} activity.type === 'spin' ? 'bg-accent-500' :
'bg-neon-500'}
`}>
<Icon className="w-2.5 h-2.5 text-white" />
</div> </div>
</Link>
{/* Content */} {/* Content */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-white truncate"> <Link
to={`/users/${activity.user.id}`}
className="text-sm font-semibold text-white hover:text-neon-400 transition-colors"
onClick={(e) => e.stopPropagation()}
>
{activity.user.nickname} {activity.user.nickname}
</span> </Link>
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-600">
{formatRelativeTime(activity.created_at)} {formatRelativeTime(activity.created_at)}
</span> </span>
</div> </div>
<div className="flex items-center gap-1.5 mt-0.5"> <div className="flex items-center gap-1.5 mt-1">
<Icon className={`w-3.5 h-3.5 flex-shrink-0 ${iconColor}`} />
<span className="text-sm text-gray-300">{title}</span> <span className="text-sm text-gray-300">{title}</span>
</div> </div>
{details && ( {details && (
<div className="text-sm text-gray-400 mt-1"> <div className="text-sm text-gray-500 mt-1">
{details} {details}
</div> </div>
)} )}
{extra && ( {extra && (
<div className="text-xs text-gray-500 mt-0.5"> <div className="text-xs text-gray-600 mt-1">
{extra} {extra}
</div> </div>
)} )}
@@ -256,19 +318,19 @@ function ActivityItem({ activity }: ActivityItemProps) {
<div className="flex items-center gap-3 mt-2"> <div className="flex items-center gap-3 mt-2">
<button <button
onClick={() => navigate(`/assignments/${assignmentId}`)} onClick={() => navigate(`/assignments/${assignmentId}`)}
className="text-xs text-primary-400 hover:text-primary-300 flex items-center gap-1" className="text-xs text-neon-400 hover:text-neon-300 flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-neon-500/10 transition-colors"
> >
<ExternalLink className="w-3 h-3" /> <ExternalLink className="w-3 h-3" />
Детали Детали
</button> </button>
{disputeStatus === 'open' && ( {disputeStatus === 'open' && (
<span className="text-xs text-orange-400 flex items-center gap-1"> <span className="text-xs text-orange-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-orange-500/10">
<AlertTriangle className="w-3 h-3" /> <AlertTriangle className="w-3 h-3" />
Оспаривается Оспаривается
</span> </span>
)} )}
{disputeStatus === 'valid' && ( {disputeStatus === 'valid' && (
<span className="text-xs text-red-400 flex items-center gap-1"> <span className="text-xs text-red-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-red-500/10">
<AlertTriangle className="w-3 h-3" /> <AlertTriangle className="w-3 h-3" />
Отклонено Отклонено
</span> </span>

View File

@@ -0,0 +1,130 @@
import { Ban, LogOut, Calendar, Clock, AlertTriangle, Sparkles } from 'lucide-react'
import { useAuthStore } from '@/store/auth'
import { NeonButton } from '@/components/ui'
interface BanInfo {
banned_at: string | null
banned_until: string | null
reason: string | null
}
interface BannedScreenProps {
banInfo: BanInfo
}
function formatDate(dateStr: string | null) {
if (!dateStr) return null
return new Date(dateStr).toLocaleString('ru-RU', {
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Moscow',
}) + ' (МСК)'
}
export function BannedScreen({ banInfo }: BannedScreenProps) {
const logout = useAuthStore((state) => state.logout)
const bannedAtFormatted = formatDate(banInfo.banned_at)
const bannedUntilFormatted = formatDate(banInfo.banned_until)
return (
<div className="min-h-screen bg-dark-900 flex flex-col items-center justify-center text-center px-4">
{/* Background effects */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 -left-32 w-96 h-96 bg-red-500/5 rounded-full blur-[100px]" />
<div className="absolute bottom-1/4 -right-32 w-96 h-96 bg-orange-500/5 rounded-full blur-[100px]" />
</div>
{/* Icon */}
<div className="relative mb-8">
<div className="w-32 h-32 rounded-3xl bg-dark-700/50 border-2 border-red-500/30 flex items-center justify-center shadow-[0_0_30px_rgba(239,68,68,0.2)]">
<Ban className="w-16 h-16 text-red-400" />
</div>
<div className="absolute -bottom-2 -right-2 w-12 h-12 rounded-xl bg-red-500/20 border border-red-500/40 flex items-center justify-center animate-pulse">
<AlertTriangle className="w-6 h-6 text-red-400" />
</div>
{/* Decorative dots */}
<div className="absolute -top-2 -left-2 w-3 h-3 rounded-full bg-red-500/50 animate-pulse" />
<div className="absolute top-4 -right-4 w-2 h-2 rounded-full bg-orange-500/50 animate-pulse" style={{ animationDelay: '0.5s' }} />
</div>
{/* Title with glow */}
<div className="relative mb-4">
<h1 className="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-red-400 via-orange-400 to-red-400">
Аккаунт заблокирован
</h1>
<div className="absolute inset-0 text-4xl font-bold text-red-500/20 blur-xl">
Аккаунт заблокирован
</div>
</div>
<p className="text-gray-400 mb-8 max-w-md">
Ваш доступ к платформе был ограничен администрацией.
</p>
{/* Ban Info Card */}
<div className="glass rounded-2xl p-6 mb-8 max-w-md w-full border border-red-500/20 text-left space-y-4">
{bannedAtFormatted && (
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-dark-700/50">
<Calendar className="w-5 h-5 text-gray-500" />
</div>
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider">Дата блокировки</p>
<p className="text-white font-medium">{bannedAtFormatted}</p>
</div>
</div>
)}
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-dark-700/50">
<Clock className="w-5 h-5 text-gray-500" />
</div>
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider">Срок</p>
<p className={`font-medium ${bannedUntilFormatted ? 'text-amber-400' : 'text-red-400'}`}>
{bannedUntilFormatted ? `до ${bannedUntilFormatted}` : 'Навсегда'}
</p>
</div>
</div>
{banInfo.reason && (
<div className="pt-4 border-t border-dark-600">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Причина</p>
<p className="text-white bg-dark-700/50 rounded-xl p-4 border border-dark-600">
{banInfo.reason}
</p>
</div>
)}
</div>
{/* Info text */}
<p className="text-gray-500 text-sm mb-8 max-w-md">
{banInfo.banned_until
? 'Ваш аккаунт будет автоматически разблокирован по истечении срока.'
: 'Если вы считаете, что блокировка ошибочна, обратитесь к администрации.'}
</p>
{/* Logout button */}
<NeonButton
variant="secondary"
size="lg"
onClick={logout}
icon={<LogOut className="w-5 h-5" />}
>
Выйти из аккаунта
</NeonButton>
{/* Decorative sparkles */}
<div className="absolute top-1/4 left-1/4 opacity-20">
<Sparkles className="w-6 h-6 text-red-400 animate-pulse" />
</div>
<div className="absolute bottom-1/3 right-1/4 opacity-20">
<Sparkles className="w-4 h-4 text-orange-400 animate-pulse" style={{ animationDelay: '1s' }} />
</div>
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,107 @@
import { useState, useEffect } from 'react'
import { usersApi } from '@/api'
// Глобальный кэш для blob URL аватарок
const avatarCache = new Map<number, string>()
// Пользователи, для которых нужно сбросить HTTP-кэш при следующем запросе
const needsCacheBust = new Set<number>()
interface UserAvatarProps {
userId: number
hasAvatar: boolean // Есть ли у пользователя avatar_url
nickname: string
size?: 'sm' | 'md' | 'lg'
className?: string
version?: number // Для принудительного обновления при смене аватара
}
const sizeClasses = {
sm: 'w-8 h-8 text-xs',
md: 'w-12 h-12 text-sm',
lg: 'w-24 h-24 text-xl',
}
export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className = '', version = 0 }: UserAvatarProps) {
const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
if (!hasAvatar) {
setBlobUrl(null)
return
}
// Если version > 0, значит аватар обновился - сбрасываем кэш
const shouldBustCache = version > 0 || needsCacheBust.has(userId)
// Проверяем кэш только если не нужен bust
if (!shouldBustCache) {
const cached = avatarCache.get(userId)
if (cached) {
setBlobUrl(cached)
return
}
}
// Очищаем старый кэш если bust
if (shouldBustCache) {
const cached = avatarCache.get(userId)
if (cached) {
URL.revokeObjectURL(cached)
avatarCache.delete(userId)
}
needsCacheBust.delete(userId)
}
// Загружаем аватарку
let cancelled = false
usersApi.getAvatarUrl(userId, shouldBustCache)
.then(url => {
if (!cancelled) {
avatarCache.set(userId, url)
setBlobUrl(url)
}
})
.catch(() => {
if (!cancelled) {
setFailed(true)
}
})
return () => {
cancelled = true
}
}, [userId, hasAvatar, version])
const sizeClass = sizeClasses[size]
if (blobUrl && !failed) {
return (
<img
src={blobUrl}
alt={nickname}
className={`rounded-full object-cover ${sizeClass} ${className}`}
/>
)
}
// Fallback - первая буква никнейма
return (
<div className={`rounded-full bg-gray-700 flex items-center justify-center ${sizeClass} ${className}`}>
<span className="text-gray-400 font-medium">
{nickname.charAt(0).toUpperCase()}
</span>
</div>
)
}
// Функция для очистки кэша конкретного пользователя (после загрузки нового аватара)
export function clearAvatarCache(userId: number) {
const cached = avatarCache.get(userId)
if (cached) {
URL.revokeObjectURL(cached)
avatarCache.delete(userId)
}
// Помечаем, что при следующем запросе нужно сбросить HTTP-кэш браузера
needsCacheBust.add(userId)
}

View File

@@ -3,3 +3,9 @@ export { Input } from './Input'
export { Card, CardHeader, CardTitle, CardContent } from './Card' 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'
// New design system components
export { GlitchText, GlitchHeading } from './GlitchText'
export { NeonButton, GradientButton } from './NeonButton'
export { GlassCard, StatsCard, FeatureCard, AnimatedBorderCard } from './GlassCard'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,8 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { marathonsApi } from '@/api' import { marathonsApi } from '@/api'
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui' import { NeonButton, Input, GlassCard } from '@/components/ui'
import { Gamepad2, LogIn, AlertCircle, Trophy, Users, Zap, Target, Shield, ArrowLeft } from 'lucide-react'
const loginSchema = z.object({ const loginSchema = z.object({
login: z.string().min(3, 'Логин должен быть не менее 3 символов'), login: z.string().min(3, 'Логин должен быть не менее 3 символов'),
@@ -16,8 +17,9 @@ type LoginForm = z.infer<typeof loginSchema>
export function LoginPage() { export function LoginPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { login, isLoading, error, clearError, consumePendingInviteCode } = useAuthStore() const { login, verify2FA, cancel2FA, pending2FA, isLoading, error, clearError, consumePendingInviteCode } = useAuthStore()
const [submitError, setSubmitError] = useState<string | null>(null) const [submitError, setSubmitError] = useState<string | null>(null)
const [twoFACode, setTwoFACode] = useState('')
const { const {
register, register,
@@ -31,7 +33,12 @@ export function LoginPage() {
setSubmitError(null) setSubmitError(null)
clearError() clearError()
try { try {
await login(data) const result = await login(data)
// If 2FA required, don't navigate
if (result.requires2FA) {
return
}
// Check for pending invite code // Check for pending invite code
const pendingCode = consumePendingInviteCode() const pendingCode = consumePendingInviteCode()
@@ -51,17 +58,154 @@ export function LoginPage() {
} }
} }
const handle2FASubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitError(null)
clearError()
try {
await verify2FA(twoFACode)
navigate('/marathons')
} catch {
setSubmitError(error || 'Неверный код')
}
}
const handleCancel2FA = () => {
cancel2FA()
setTwoFACode('')
setSubmitError(null)
}
const features = [
{ icon: <Trophy className="w-5 h-5" />, text: 'Соревнуйтесь с друзьями' },
{ icon: <Target className="w-5 h-5" />, text: 'Выполняйте челленджи' },
{ icon: <Zap className="w-5 h-5" />, text: 'Зарабатывайте очки' },
{ icon: <Users className="w-5 h-5" />, text: 'Создавайте марафоны' },
]
return ( return (
<div className="max-w-md mx-auto"> <div className="min-h-[80vh] flex items-center justify-center px-4 -mt-8">
<Card> {/* Background effects */}
<CardHeader> <div className="fixed inset-0 overflow-hidden pointer-events-none">
<CardTitle className="text-center">Вход</CardTitle> <div className="absolute top-1/4 -left-32 w-96 h-96 bg-neon-500/10 rounded-full blur-[100px]" />
</CardHeader> <div className="absolute bottom-1/4 -right-32 w-96 h-96 bg-accent-500/10 rounded-full blur-[100px]" />
<CardContent> </div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Bento Grid */}
<div className="relative w-full max-w-4xl">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-scale-in">
{/* Branding Block (left) */}
<GlassCard className="p-8 flex flex-col justify-center relative overflow-hidden" variant="neon">
{/* Background decoration */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-20 -left-20 w-48 h-48 bg-neon-500/20 rounded-full blur-[60px]" />
<div className="absolute -bottom-20 -right-20 w-48 h-48 bg-accent-500/20 rounded-full blur-[60px]" />
</div>
<div className="relative">
{/* Logo */}
<div className="flex justify-center md:justify-start mb-6">
<div className="w-20 h-20 rounded-2xl bg-neon-500/10 border border-neon-500/30 flex items-center justify-center shadow-[0_0_24px_rgba(34,211,238,0.25)]">
<Gamepad2 className="w-10 h-10 text-neon-500" />
</div>
</div>
{/* Title */}
<h1 className="text-3xl md:text-4xl font-bold text-white mb-2 text-center md:text-left">
Game Marathon
</h1>
<p className="text-gray-400 mb-8 text-center md:text-left">
Платформа для игровых соревнований
</p>
{/* Features */}
<div className="grid grid-cols-2 gap-3">
{features.map((feature, index) => (
<div
key={index}
className="flex items-center gap-2 p-3 rounded-xl bg-dark-700/50 border border-dark-600"
>
<div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center text-neon-400">
{feature.icon}
</div>
<span className="text-sm text-gray-300">{feature.text}</span>
</div>
))}
</div>
</div>
</GlassCard>
{/* Form Block (right) */}
<GlassCard className="p-8">
{pending2FA ? (
// 2FA Form
<>
{/* Header */}
<div className="text-center mb-8">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-neon-500/10 border border-neon-500/30 flex items-center justify-center">
<Shield className="w-8 h-8 text-neon-500" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">Двухфакторная аутентификация</h2>
<p className="text-gray-400">Введите код из Telegram</p>
</div>
{/* 2FA Form */}
<form onSubmit={handle2FASubmit} 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>
)}
<Input
label="Код подтверждения"
placeholder="000000"
value={twoFACode}
onChange={(e) => setTwoFACode(e.target.value.replace(/\D/g, '').slice(0, 6))}
maxLength={6}
className="text-center text-2xl tracking-widest font-mono"
autoFocus
/>
<NeonButton
type="submit"
className="w-full"
size="lg"
isLoading={isLoading}
disabled={twoFACode.length !== 6}
icon={<Shield className="w-5 h-5" />}
>
Подтвердить
</NeonButton>
</form>
{/* Back button */}
<div className="mt-6 pt-6 border-t border-dark-600 text-center">
<button
onClick={handleCancel2FA}
className="text-gray-400 hover:text-white transition-colors text-sm flex items-center justify-center gap-2 mx-auto"
>
<ArrowLeft className="w-4 h-4" />
Вернуться к входу
</button>
</div>
</>
) : (
// Regular Login Form
<>
{/* Header */}
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-white mb-2">Добро пожаловать!</h2>
<p className="text-gray-400">Войдите, чтобы продолжить</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{(submitError || error) && (
<div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span>{submitError || error}</span>
</div> </div>
)} )}
@@ -69,6 +213,7 @@ export function LoginPage() {
label="Логин" label="Логин"
placeholder="Введите логин" placeholder="Введите логин"
error={errors.login?.message} error={errors.login?.message}
autoComplete="username"
{...register('login')} {...register('login')}
/> />
@@ -77,22 +222,42 @@ 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> </>
</Card> )}
</GlassCard>
</div>
{/* Decorative elements */}
<div className="absolute -top-4 -right-4 w-24 h-24 border border-neon-500/20 rounded-2xl -z-10 hidden md:block" />
<div className="absolute -bottom-4 -left-4 w-32 h-32 border border-accent-500/20 rounded-2xl -z-10 hidden md:block" />
</div>
</div> </div>
) )
} }

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -7,15 +7,15 @@ import { usersApi, telegramApi, authApi } from '@/api'
import type { UserStats } from '@/types' import type { UserStats } from '@/types'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { import {
Button, Input, Card, CardHeader, CardTitle, CardContent NeonButton, Input, GlassCard, StatsCard, clearAvatarCache
} from '@/components/ui' } from '@/components/ui'
import { import {
User, Camera, Trophy, Target, CheckCircle, Flame, User, Camera, Trophy, Target, CheckCircle, Flame,
Loader2, MessageCircle, Link2, Link2Off, ExternalLink, Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
Eye, EyeOff, Save, KeyRound Eye, EyeOff, Save, KeyRound, Shield
} from 'lucide-react' } from 'lucide-react'
// Схемы валидации // Schemas
const nicknameSchema = z.object({ const nicknameSchema = z.object({
nickname: z.string().min(2, 'Минимум 2 символа').max(50, 'Максимум 50 символов'), nickname: z.string().min(2, 'Минимум 2 символа').max(50, 'Максимум 50 символов'),
}) })
@@ -33,16 +33,18 @@ type NicknameForm = z.infer<typeof nicknameSchema>
type PasswordForm = z.infer<typeof passwordSchema> type PasswordForm = z.infer<typeof passwordSchema>
export function ProfilePage() { export function ProfilePage() {
const { user, updateUser } = useAuthStore() const { user, updateUser, avatarVersion, bumpAvatarVersion } = useAuthStore()
const toast = useToast() const toast = useToast()
// Состояние // State
const [stats, setStats] = useState<UserStats | null>(null) const [stats, setStats] = useState<UserStats | null>(null)
const [isLoadingStats, setIsLoadingStats] = useState(true) const [isLoadingStats, setIsLoadingStats] = useState(true)
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false) const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
const [showPasswordForm, setShowPasswordForm] = useState(false) const [showPasswordForm, setShowPasswordForm] = useState(false)
const [showCurrentPassword, setShowCurrentPassword] = useState(false) const [showCurrentPassword, setShowCurrentPassword] = useState(false)
const [showNewPassword, setShowNewPassword] = useState(false) const [showNewPassword, setShowNewPassword] = useState(false)
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null)
const [isLoadingAvatar, setIsLoadingAvatar] = useState(true)
// Telegram state // Telegram state
const [telegramLoading, setTelegramLoading] = useState(false) const [telegramLoading, setTelegramLoading] = useState(false)
@@ -51,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 || '' },
@@ -62,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 () => {
@@ -70,7 +72,59 @@ export function ProfilePage() {
} }
}, []) }, [])
// Обновляем форму никнейма при изменении user // Ref для отслеживания текущего blob URL
const avatarBlobRef = useRef<string | null>(null)
// Load avatar via API
useEffect(() => {
if (!user?.id || !user?.avatar_url) {
setIsLoadingAvatar(false)
return
}
let cancelled = false
const bustCache = avatarVersion > 0
setIsLoadingAvatar(true)
usersApi.getAvatarUrl(user.id, bustCache)
.then(url => {
if (cancelled) {
URL.revokeObjectURL(url)
return
}
// Очищаем старый blob URL
if (avatarBlobRef.current) {
URL.revokeObjectURL(avatarBlobRef.current)
}
avatarBlobRef.current = url
setAvatarBlobUrl(url)
})
.catch(() => {
if (!cancelled) {
setAvatarBlobUrl(null)
}
})
.finally(() => {
if (!cancelled) {
setIsLoadingAvatar(false)
}
})
return () => {
cancelled = true
}
}, [user?.id, user?.avatar_url, avatarVersion])
// Cleanup blob URL on unmount
useEffect(() => {
return () => {
if (avatarBlobRef.current) {
URL.revokeObjectURL(avatarBlobRef.current)
}
}
}, [])
// Update nickname form when user changes
useEffect(() => { useEffect(() => {
if (user?.nickname) { if (user?.nickname) {
nicknameForm.reset({ nickname: user.nickname }) nicknameForm.reset({ nickname: user.nickname })
@@ -88,7 +142,7 @@ export function ProfilePage() {
} }
} }
// Обновление никнейма // Update nickname
const onNicknameSubmit = async (data: NicknameForm) => { const onNicknameSubmit = async (data: NicknameForm) => {
try { try {
const updatedUser = await usersApi.updateNickname(data) const updatedUser = await usersApi.updateNickname(data)
@@ -99,7 +153,7 @@ export function ProfilePage() {
} }
} }
// Загрузка аватара // Upload avatar
const handleAvatarClick = () => { const handleAvatarClick = () => {
fileInputRef.current?.click() fileInputRef.current?.click()
} }
@@ -108,7 +162,6 @@ export function ProfilePage() {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
// Валидация
if (!file.type.startsWith('image/')) { if (!file.type.startsWith('image/')) {
toast.error('Файл должен быть изображением') toast.error('Файл должен быть изображением')
return return
@@ -122,6 +175,11 @@ export function ProfilePage() {
try { try {
const updatedUser = await usersApi.uploadAvatar(file) const updatedUser = await usersApi.uploadAvatar(file)
updateUser({ avatar_url: updatedUser.avatar_url }) updateUser({ avatar_url: updatedUser.avatar_url })
if (user?.id) {
clearAvatarCache(user.id)
}
// Bump version - это вызовет перезагрузку через useEffect
bumpAvatarVersion()
toast.success('Аватар обновлен') toast.success('Аватар обновлен')
} catch { } catch {
toast.error('Не удалось загрузить аватар') toast.error('Не удалось загрузить аватар')
@@ -130,7 +188,7 @@ export function ProfilePage() {
} }
} }
// Смена пароля // Change password
const onPasswordSubmit = async (data: PasswordForm) => { const onPasswordSubmit = async (data: PasswordForm) => {
try { try {
await usersApi.changePassword({ await usersApi.changePassword({
@@ -147,7 +205,7 @@ export function ProfilePage() {
} }
} }
// Telegram функции // Telegram functions
const startPolling = () => { const startPolling = () => {
setIsPolling(true) setIsPolling(true)
let attempts = 0 let attempts = 0
@@ -208,22 +266,28 @@ export function ProfilePage() {
} }
const isLinked = !!user?.telegram_id const isLinked = !!user?.telegram_id
const displayAvatar = user?.telegram_avatar_url || user?.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 ? (
<div className="w-28 h-28 rounded-2xl bg-dark-700 skeleton" />
) : (
<button <button
onClick={handleAvatarClick} onClick={handleAvatarClick}
disabled={isUploadingAvatar} disabled={isUploadingAvatar}
className="relative w-24 h-24 rounded-full overflow-hidden bg-gray-700 hover:opacity-80 transition-opacity" className="relative w-28 h-28 rounded-2xl overflow-hidden bg-dark-700 border-2 border-neon-500/30 hover:border-neon-500 transition-all group-hover:shadow-[0_0_20px_rgba(34,211,238,0.25)]"
> >
{displayAvatar ? ( {displayAvatar ? (
<img <img
@@ -232,18 +296,19 @@ 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>
)}
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@@ -253,84 +318,96 @@ export function ProfilePage() {
/> />
</div> </div>
{/* Форма никнейма */} {/* Nickname Form */}
<div className="flex-1"> <div className="flex-1 w-full sm:w-auto">
<form onSubmit={nicknameForm.handleSubmit(onNicknameSubmit)} className="space-y-4"> <form onSubmit={nicknameForm.handleSubmit(onNicknameSubmit)} className="space-y-4">
<Input <Input
label="Никнейм" label="Никнейм"
{...nicknameForm.register('nickname')} {...nicknameForm.register('nickname')}
error={nicknameForm.formState.errors.nickname?.message} error={nicknameForm.formState.errors.nickname?.message}
/> />
<Button <NeonButton
type="submit" type="submit"
size="sm" size="sm"
isLoading={nicknameForm.formState.isSubmitting} isLoading={nicknameForm.formState.isSubmitting}
disabled={!nicknameForm.formState.isDirty} disabled={!nicknameForm.formState.isDirty}
icon={<Save className="w-4 h-4" />}
> >
<Save className="w-4 h-4 mr-2" />
Сохранить Сохранить
</Button> </NeonButton>
</form> </form>
</div> </div>
</div> </div>
</CardContent> </GlassCard>
</Card>
{/* Статистика */} {/* Stats */}
<Card> <div>
<CardHeader> <h2 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
<CardTitle className="flex items-center gap-2">
<Trophy className="w-5 h-5 text-yellow-500" /> <Trophy className="w-5 h-5 text-yellow-500" />
Статистика Статистика
</CardTitle> </h2>
</CardHeader>
<CardContent>
{isLoadingStats ? ( {isLoadingStats ? (
<div className="flex justify-center py-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Loader2 className="w-6 h-6 animate-spin text-primary-500" /> {[...Array(4)].map((_, i) => (
<div key={i} className="glass rounded-xl p-4">
<div className="w-12 h-12 bg-dark-700 rounded-lg mb-3 skeleton" />
<div className="h-8 w-16 bg-dark-700 rounded mb-2 skeleton" />
<div className="h-4 w-20 bg-dark-700 rounded skeleton" />
</div>
))}
</div> </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}
@@ -338,7 +415,7 @@ export function ProfilePage() {
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : ( ) : (
<Link2 className="w-6 h-6 text-blue-400" /> <Link2 className="w-7 h-7 text-blue-400" />
)} )}
</div> </div>
<div className="flex-1"> <div className="flex-1">
@@ -349,53 +426,61 @@ export function ProfilePage() {
<p className="text-blue-400 text-sm">@{user.telegram_username}</p> <p className="text-blue-400 text-sm">@{user.telegram_username}</p>
)} )}
</div> </div>
<Button <NeonButton
variant="danger" variant="danger"
size="sm" size="sm"
onClick={handleUnlinkTelegram} onClick={handleUnlinkTelegram}
isLoading={telegramLoading} isLoading={telegramLoading}
icon={<Link2Off className="w-4 h-4" />}
> >
<Link2Off className="w-4 h-4 mr-2" />
Отвязать Отвязать
</Button> </NeonButton>
</div> </div>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-gray-400"> <p className="text-gray-400">
Привяжи Telegram для получения уведомлений о событиях и марафонах. Привяжите Telegram для получения уведомлений о событиях и марафонах.
</p> </p>
{isPolling ? ( {isPolling ? (
<div className="p-4 bg-blue-500/20 border border-blue-500/50 rounded-lg"> <div className="p-4 bg-blue-500/10 border border-blue-500/30 rounded-xl">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Loader2 className="w-5 h-5 text-blue-400 animate-spin" /> <Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
<p className="text-blue-400">Ожидание привязки...</p> <p className="text-blue-400">Ожидание привязки...</p>
</div> </div>
</div> </div>
) : ( ) : (
<Button onClick={handleLinkTelegram} isLoading={telegramLoading}> <NeonButton
<ExternalLink className="w-4 h-4 mr-2" /> onClick={handleLinkTelegram}
isLoading={telegramLoading}
icon={<ExternalLink className="w-4 h-4" />}
>
Привязать Telegram Привязать Telegram
</Button> </NeonButton>
)} )}
</div> </div>
)} )}
</CardContent> </GlassCard>
</Card>
{/* Security */}
<GlassCard>
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-xl bg-accent-500/20 flex items-center justify-center">
<Shield className="w-6 h-6 text-accent-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Безопасность</h2>
<p className="text-sm text-gray-400">Управление паролем</p>
</div>
</div>
{/* Смена пароля */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="w-5 h-5 text-gray-400" />
Безопасность
</CardTitle>
</CardHeader>
<CardContent>
{!showPasswordForm ? ( {!showPasswordForm ? (
<Button variant="secondary" onClick={() => setShowPasswordForm(true)}> <NeonButton
onClick={() => setShowPasswordForm(true)}
icon={<KeyRound className="w-4 h-4" />}
>
Сменить пароль Сменить пароль
</Button> </NeonButton>
) : ( ) : (
<form onSubmit={passwordForm.handleSubmit(onPasswordSubmit)} className="space-y-4"> <form onSubmit={passwordForm.handleSubmit(onPasswordSubmit)} className="space-y-4">
<div className="relative"> <div className="relative">
@@ -408,7 +493,7 @@ export function ProfilePage() {
<button <button
type="button" type="button"
onClick={() => setShowCurrentPassword(!showCurrentPassword)} onClick={() => setShowCurrentPassword(!showCurrentPassword)}
className="absolute right-3 top-8 text-gray-400 hover:text-white" className="absolute right-3 top-9 text-gray-400 hover:text-white transition-colors"
> >
{showCurrentPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />} {showCurrentPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button> </button>
@@ -424,7 +509,7 @@ export function ProfilePage() {
<button <button
type="button" type="button"
onClick={() => setShowNewPassword(!showNewPassword)} onClick={() => setShowNewPassword(!showNewPassword)}
className="absolute right-3 top-8 text-gray-400 hover:text-white" className="absolute right-3 top-9 text-gray-400 hover:text-white transition-colors"
> >
{showNewPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />} {showNewPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button> </button>
@@ -437,11 +522,15 @@ export function ProfilePage() {
error={passwordForm.formState.errors.confirm_password?.message} error={passwordForm.formState.errors.confirm_password?.message}
/> />
<div className="flex gap-2"> <div className="flex gap-3">
<Button type="submit" isLoading={passwordForm.formState.isSubmitting}> <NeonButton
type="submit"
isLoading={passwordForm.formState.isSubmitting}
icon={<Save className="w-4 h-4" />}
>
Сменить пароль Сменить пароль
</Button> </NeonButton>
<Button <NeonButton
type="button" type="button"
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
@@ -450,12 +539,11 @@ export function ProfilePage() {
}} }}
> >
Отмена Отмена
</Button> </NeonButton>
</div> </div>
</form> </form>
)} )}
</CardContent> </GlassCard>
</Card>
</div> </div>
) )
} }

View File

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

View File

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

View File

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

View File

@@ -3,10 +3,10 @@ import { useParams, useNavigate, Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { usersApi } from '@/api' import { usersApi } from '@/api'
import type { UserProfilePublic } from '@/types' import type { UserProfilePublic } from '@/types'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui' import { GlassCard, StatsCard } from '@/components/ui'
import { import {
User, Trophy, Target, CheckCircle, Flame, User, Trophy, Target, CheckCircle, Flame,
Loader2, ArrowLeft, Calendar Loader2, ArrowLeft, Calendar, Zap
} from 'lucide-react' } from 'lucide-react'
export function UserProfilePage() { export function UserProfilePage() {
@@ -17,6 +17,7 @@ export function UserProfilePage() {
const [profile, setProfile] = useState<UserProfilePublic | null>(null) const [profile, setProfile] = useState<UserProfilePublic | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
if (!id) return if (!id) return
@@ -32,6 +33,27 @@ export function UserProfilePage() {
loadProfile(userId) loadProfile(userId)
}, [id, currentUser, navigate]) }, [id, currentUser, navigate])
// Загрузка аватарки через API
useEffect(() => {
if (profile?.id && profile?.avatar_url) {
loadAvatar(profile.id)
}
return () => {
if (avatarBlobUrl) {
URL.revokeObjectURL(avatarBlobUrl)
}
}
}, [profile?.id, profile?.avatar_url])
const loadAvatar = async (userId: number) => {
try {
const url = await usersApi.getAvatarUrl(userId)
setAvatarBlobUrl(url)
} catch {
setAvatarBlobUrl(null)
}
}
const loadProfile = async (userId: number) => { const loadProfile = async (userId: number) => {
setIsLoading(true) setIsLoading(true)
setError(null) setError(null)
@@ -60,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>
) )
} }
@@ -69,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>
) )
} }
@@ -89,30 +112,35 @@ 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">
{profile.avatar_url ? ( <div className="w-24 h-24 rounded-2xl overflow-hidden bg-dark-700 border-2 border-neon-500/30 shadow-[0_0_14px_rgba(34,211,238,0.15)]">
{avatarBlobUrl ? (
<img <img
src={profile.avatar_url} src={avatarBlobUrl}
alt={profile.nickname} alt={profile.nickname}
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>
@@ -120,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

@@ -0,0 +1,190 @@
import { useState, useEffect } from 'react'
import { adminApi } from '@/api'
import type { AdminMarathon } from '@/types'
import { useToast } from '@/store/toast'
import { NeonButton } from '@/components/ui'
import { Send, Users, Trophy, AlertTriangle } from 'lucide-react'
export function AdminBroadcastPage() {
const [message, setMessage] = useState('')
const [targetType, setTargetType] = useState<'all' | 'marathon'>('all')
const [marathonId, setMarathonId] = useState<number | null>(null)
const [marathons, setMarathons] = useState<AdminMarathon[]>([])
const [sending, setSending] = useState(false)
const [loadingMarathons, setLoadingMarathons] = useState(false)
const toast = useToast()
useEffect(() => {
if (targetType === 'marathon') {
loadMarathons()
}
}, [targetType])
const loadMarathons = async () => {
setLoadingMarathons(true)
try {
const data = await adminApi.listMarathons(0, 100)
setMarathons(data.filter(m => m.status === 'active'))
} catch (err) {
console.error('Failed to load marathons:', err)
} finally {
setLoadingMarathons(false)
}
}
const handleSend = async () => {
if (!message.trim()) {
toast.error('Введите сообщение')
return
}
if (targetType === 'marathon' && !marathonId) {
toast.error('Выберите марафон')
return
}
setSending(true)
try {
let result
if (targetType === 'all') {
result = await adminApi.broadcastToAll(message)
} else {
result = await adminApi.broadcastToMarathon(marathonId!, message)
}
toast.success(`Отправлено ${result.sent_count} из ${result.total_count} сообщений`)
setMessage('')
} catch (err) {
console.error('Failed to send broadcast:', err)
toast.error('Ошибка отправки')
} finally {
setSending(false)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-pink-500/20 border border-pink-500/30">
<Send className="w-6 h-6 text-pink-400" />
</div>
<h1 className="text-2xl font-bold text-white">Рассылка уведомлений</h1>
</div>
<div className="max-w-2xl space-y-6">
{/* Target Selection */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-300">
Кому отправить
</label>
<div className="flex gap-4">
<button
onClick={() => {
setTargetType('all')
setMarathonId(null)
}}
className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-xl border transition-all duration-200 ${
targetType === 'all'
? 'bg-accent-500/20 border-accent-500/50 text-accent-400 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
: 'bg-dark-700/50 border-dark-600 text-gray-400 hover:border-dark-500 hover:text-gray-300'
}`}
>
<Users className="w-5 h-5" />
<span className="font-medium">Всем пользователям</span>
</button>
<button
onClick={() => setTargetType('marathon')}
className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-xl border transition-all duration-200 ${
targetType === 'marathon'
? 'bg-accent-500/20 border-accent-500/50 text-accent-400 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
: 'bg-dark-700/50 border-dark-600 text-gray-400 hover:border-dark-500 hover:text-gray-300'
}`}
>
<Trophy className="w-5 h-5" />
<span className="font-medium">Участникам марафона</span>
</button>
</div>
</div>
{/* Marathon Selection */}
{targetType === 'marathon' && (
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-300">
Выберите марафон
</label>
{loadingMarathons ? (
<div className="animate-pulse bg-dark-700 h-12 rounded-xl" />
) : (
<select
value={marathonId || ''}
onChange={(e) => setMarathonId(Number(e.target.value) || null)}
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-accent-500/50 transition-colors"
>
<option value="">Выберите марафон...</option>
{marathons.map((m) => (
<option key={m.id} value={m.id}>
{m.title} ({m.participants_count} участников)
</option>
))}
</select>
)}
{marathons.length === 0 && !loadingMarathons && (
<p className="text-sm text-gray-500">Нет активных марафонов</p>
)}
</div>
)}
{/* Message */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-300">
Сообщение
</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={6}
placeholder="Введите текст сообщения... (поддерживается HTML: <b>, <i>, <code>)"
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors resize-none"
/>
<div className="flex items-center justify-between text-xs">
<p className="text-gray-500">
Поддерживается HTML: &lt;b&gt;, &lt;i&gt;, &lt;code&gt;, &lt;a href&gt;
</p>
<p className={`${message.length > 2000 ? 'text-red-400' : 'text-gray-500'}`}>
{message.length} / 2000
</p>
</div>
</div>
{/* Send Button */}
<NeonButton
size="lg"
color="purple"
onClick={handleSend}
disabled={sending || !message.trim() || message.length > 2000 || (targetType === 'marathon' && !marathonId)}
isLoading={sending}
icon={<Send className="w-5 h-5" />}
className="w-full"
>
{sending ? 'Отправка...' : 'Отправить рассылку'}
</NeonButton>
{/* Warning */}
<div className="glass rounded-xl p-4 border border-amber-500/20">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-amber-400 font-medium mb-1">Обратите внимание</p>
<p className="text-sm text-gray-400">
Сообщение будет отправлено только пользователям с привязанным Telegram.
Рассылка ограничена: 1 сообщение всем в минуту, 3 сообщения марафону в минуту.
</p>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,261 @@
import { useState, useEffect } from 'react'
import { adminApi } from '@/api'
import type { StaticContent } from '@/types'
import { useToast } from '@/store/toast'
import { NeonButton } from '@/components/ui'
import { FileText, Plus, Pencil, X, Save, Code } from 'lucide-react'
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
export function AdminContentPage() {
const [contents, setContents] = useState<StaticContent[]>([])
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState<StaticContent | null>(null)
const [creating, setCreating] = useState(false)
const [saving, setSaving] = useState(false)
// Form state
const [formKey, setFormKey] = useState('')
const [formTitle, setFormTitle] = useState('')
const [formContent, setFormContent] = useState('')
const toast = useToast()
useEffect(() => {
loadContents()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const loadContents = async () => {
try {
const data = await adminApi.listContent()
setContents(data)
} catch (err) {
console.error('Failed to load contents:', err)
toast.error('Ошибка загрузки контента')
} finally {
setLoading(false)
}
}
const handleEdit = (content: StaticContent) => {
setEditing(content)
setFormKey(content.key)
setFormTitle(content.title)
setFormContent(content.content)
setCreating(false)
}
const handleCreate = () => {
setCreating(true)
setEditing(null)
setFormKey('')
setFormTitle('')
setFormContent('')
}
const handleCancel = () => {
setEditing(null)
setCreating(false)
setFormKey('')
setFormTitle('')
setFormContent('')
}
const handleSave = async () => {
if (!formTitle.trim() || !formContent.trim()) {
toast.error('Заполните все поля')
return
}
if (creating && !formKey.trim()) {
toast.error('Введите ключ')
return
}
setSaving(true)
try {
if (creating) {
const newContent = await adminApi.createContent(formKey, formTitle, formContent)
setContents([...contents, newContent])
toast.success('Контент создан')
} else if (editing) {
const updated = await adminApi.updateContent(editing.key, formTitle, formContent)
setContents(contents.map(c => c.id === updated.id ? updated : c))
toast.success('Контент обновлён')
}
handleCancel()
} catch (err) {
console.error('Failed to save content:', err)
toast.error('Ошибка сохранения')
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-accent-500 border-t-transparent" />
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-neon-500/20 border border-neon-500/30">
<FileText className="w-6 h-6 text-neon-400" />
</div>
<h1 className="text-2xl font-bold text-white">Статический контент</h1>
</div>
<NeonButton onClick={handleCreate} icon={<Plus className="w-4 h-4" />}>
Добавить
</NeonButton>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Content List */}
<div className="space-y-4">
{contents.length === 0 ? (
<div className="glass rounded-xl border border-dark-600 p-8 text-center">
<FileText className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400">Нет статического контента</p>
<p className="text-sm text-gray-500 mt-1">Создайте первую страницу</p>
</div>
) : (
contents.map((content) => (
<div
key={content.id}
className={`glass rounded-xl border p-5 cursor-pointer transition-all duration-200 ${
editing?.id === content.id
? 'border-accent-500/50 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
: 'border-dark-600 hover:border-dark-500'
}`}
onClick={() => handleEdit(content)}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<Code className="w-4 h-4 text-neon-400" />
<p className="text-sm text-neon-400 font-mono">{content.key}</p>
</div>
<h3 className="text-lg font-medium text-white truncate">{content.title}</h3>
<p className="text-sm text-gray-400 mt-2 line-clamp-2">
{content.content.replace(/<[^>]*>/g, '').slice(0, 100)}...
</p>
</div>
<button
onClick={(e) => {
e.stopPropagation()
handleEdit(content)
}}
className="p-2 text-gray-400 hover:text-accent-400 hover:bg-accent-500/10 rounded-lg transition-colors ml-3"
>
<Pencil className="w-4 h-4" />
</button>
</div>
<p className="text-xs text-gray-500 mt-4 pt-3 border-t border-dark-600">
Обновлено: {formatDate(content.updated_at)}
</p>
</div>
))
)}
</div>
{/* Editor */}
{(editing || creating) && (
<div className="glass rounded-xl border border-dark-600 p-6 sticky top-6 h-fit">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
{creating ? (
<>
<Plus className="w-5 h-5 text-neon-400" />
Новый контент
</>
) : (
<>
<Pencil className="w-5 h-5 text-accent-400" />
Редактирование
</>
)}
</h2>
<button
onClick={handleCancel}
className="p-2 text-gray-400 hover:text-white hover:bg-dark-600/50 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
{creating && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Ключ
</label>
<input
type="text"
value={formKey}
onChange={(e) => setFormKey(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ''))}
placeholder="about-page"
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white font-mono placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors"
/>
<p className="text-xs text-gray-500 mt-1.5">
Только буквы, цифры, дефисы и подчеркивания
</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Заголовок
</label>
<input
type="text"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
placeholder="Заголовок страницы"
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Содержимое (HTML)
</label>
<textarea
value={formContent}
onChange={(e) => setFormContent(e.target.value)}
rows={14}
placeholder="<p>HTML контент...</p>"
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 font-mono text-sm resize-none transition-colors"
/>
</div>
<NeonButton
onClick={handleSave}
disabled={saving}
isLoading={saving}
icon={<Save className="w-4 h-4" />}
className="w-full"
>
{saving ? 'Сохранение...' : 'Сохранить'}
</NeonButton>
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,207 @@
import { useState, useEffect } from 'react'
import { adminApi } from '@/api'
import type { DashboardStats } from '@/types'
import { Users, Trophy, Gamepad2, UserCheck, Ban, Activity, TrendingUp } from 'lucide-react'
const ACTION_LABELS: Record<string, string> = {
user_ban: 'Бан пользователя',
user_unban: 'Разбан пользователя',
user_role_change: 'Изменение роли',
marathon_force_finish: 'Принудительное завершение',
marathon_delete: 'Удаление марафона',
content_update: 'Обновление контента',
broadcast_all: 'Рассылка всем',
broadcast_marathon: 'Рассылка марафону',
admin_login: 'Вход админа',
admin_2fa_success: '2FA успех',
admin_2fa_fail: '2FA неудача',
}
const ACTION_COLORS: Record<string, string> = {
user_ban: 'text-red-400',
user_unban: 'text-green-400',
user_role_change: 'text-accent-400',
marathon_force_finish: 'text-orange-400',
marathon_delete: 'text-red-400',
content_update: 'text-neon-400',
broadcast_all: 'text-pink-400',
broadcast_marathon: 'text-pink-400',
admin_login: 'text-blue-400',
admin_2fa_success: 'text-green-400',
admin_2fa_fail: 'text-red-400',
}
function StatCard({
icon: Icon,
label,
value,
gradient,
glowColor
}: {
icon: typeof Users
label: string
value: number
gradient: string
glowColor: string
}) {
return (
<div className={`glass rounded-xl p-5 border border-dark-600 hover:border-dark-500 transition-all duration-300 hover:shadow-[0_0_20px_${glowColor}]`}>
<div className="flex items-center gap-4">
<div className={`p-3 rounded-xl ${gradient} shadow-lg`}>
<Icon className="w-6 h-6 text-white" />
</div>
<div>
<p className="text-sm text-gray-400">{label}</p>
<p className="text-2xl font-bold text-white">{value.toLocaleString()}</p>
</div>
</div>
</div>
)
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
export function AdminDashboardPage() {
const [stats, setStats] = useState<DashboardStats | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
loadDashboard()
}, [])
const loadDashboard = async () => {
try {
const data = await adminApi.getDashboard()
setStats(data)
} catch (err) {
console.error('Failed to load dashboard:', err)
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-accent-500 border-t-transparent" />
</div>
)
}
if (!stats) {
return (
<div className="text-center text-gray-400 py-12">
Не удалось загрузить данные
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-accent-500/20 border border-accent-500/30">
<TrendingUp className="w-6 h-6 text-accent-400" />
</div>
<h1 className="text-2xl font-bold text-white">Дашборд</h1>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<StatCard
icon={Users}
label="Всего пользователей"
value={stats.users_count}
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
glowColor="rgba(59,130,246,0.15)"
/>
<StatCard
icon={Ban}
label="Заблокировано"
value={stats.banned_users_count}
gradient="bg-gradient-to-br from-red-500 to-red-600"
glowColor="rgba(239,68,68,0.15)"
/>
<StatCard
icon={Trophy}
label="Всего марафонов"
value={stats.marathons_count}
gradient="bg-gradient-to-br from-accent-500 to-pink-500"
glowColor="rgba(139,92,246,0.15)"
/>
<StatCard
icon={Activity}
label="Активных марафонов"
value={stats.active_marathons_count}
gradient="bg-gradient-to-br from-green-500 to-emerald-600"
glowColor="rgba(34,197,94,0.15)"
/>
<StatCard
icon={Gamepad2}
label="Всего игр"
value={stats.games_count}
gradient="bg-gradient-to-br from-orange-500 to-amber-500"
glowColor="rgba(249,115,22,0.15)"
/>
<StatCard
icon={UserCheck}
label="Участий в марафонах"
value={stats.total_participations}
gradient="bg-gradient-to-br from-neon-500 to-cyan-500"
glowColor="rgba(34,211,238,0.15)"
/>
</div>
{/* Recent Logs */}
<div className="glass rounded-xl border border-dark-600 overflow-hidden">
<div className="p-4 border-b border-dark-600">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Activity className="w-5 h-5 text-accent-400" />
Последние действия
</h2>
</div>
<div className="p-4">
{stats.recent_logs.length === 0 ? (
<p className="text-gray-400 text-center py-4">Нет записей</p>
) : (
<div className="space-y-3">
{stats.recent_logs.map((log) => (
<div
key={log.id}
className="flex items-start justify-between p-4 bg-dark-700/50 hover:bg-dark-700 rounded-xl border border-dark-600 transition-colors"
>
<div>
<p className={`font-medium ${ACTION_COLORS[log.action] || 'text-white'}`}>
{ACTION_LABELS[log.action] || log.action}
</p>
<p className="text-sm text-gray-400 mt-1">
<span className="text-gray-500">Админ:</span> {log.admin_nickname}
<span className="text-gray-600 mx-2"></span>
<span className="text-gray-500">{log.target_type}</span> #{log.target_id}
</p>
{log.details && (
<p className="text-xs text-gray-500 mt-2 font-mono bg-dark-800 rounded px-2 py-1 inline-block">
{JSON.stringify(log.details)}
</p>
)}
</div>
<span className="text-xs text-gray-500 whitespace-nowrap ml-4">
{formatDate(log.created_at)}
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,169 @@
import { Outlet, NavLink, Navigate, Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { NeonButton } from '@/components/ui'
import {
LayoutDashboard,
Users,
Trophy,
ScrollText,
Send,
FileText,
ArrowLeft,
Shield,
MessageCircle,
Sparkles,
Lock
} from 'lucide-react'
const navItems = [
{ to: '/admin', icon: LayoutDashboard, label: 'Дашборд', end: true },
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
{ to: '/admin/marathons', icon: Trophy, label: 'Марафоны' },
{ to: '/admin/logs', icon: ScrollText, label: 'Логи' },
{ to: '/admin/broadcast', icon: Send, label: 'Рассылка' },
{ to: '/admin/content', icon: FileText, label: 'Контент' },
]
export function AdminLayout() {
const user = useAuthStore((state) => state.user)
// Only admins can access
if (!user || user.role !== 'admin') {
return <Navigate to="/" replace />
}
// Admin without Telegram - show warning
if (!user.telegram_id) {
return (
<div className="min-h-[70vh] flex flex-col items-center justify-center text-center px-4">
{/* Background effects */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/3 -left-32 w-96 h-96 bg-amber-500/5 rounded-full blur-[100px]" />
<div className="absolute bottom-1/3 -right-32 w-96 h-96 bg-accent-500/5 rounded-full blur-[100px]" />
</div>
{/* Icon */}
<div className="relative mb-8 animate-float">
<div className="w-32 h-32 rounded-3xl bg-dark-700/50 border border-amber-500/30 flex items-center justify-center shadow-[0_0_30px_rgba(245,158,11,0.15)]">
<Lock className="w-16 h-16 text-amber-400" />
</div>
<div className="absolute -bottom-2 -right-2 w-12 h-12 rounded-xl bg-accent-500/20 border border-accent-500/30 flex items-center justify-center">
<Shield className="w-6 h-6 text-accent-400" />
</div>
{/* Decorative dots */}
<div className="absolute -top-2 -left-2 w-3 h-3 rounded-full bg-amber-500/50 animate-pulse" />
<div className="absolute top-4 -right-4 w-2 h-2 rounded-full bg-accent-500/50 animate-pulse" style={{ animationDelay: '0.5s' }} />
</div>
{/* Title with glow */}
<div className="relative mb-4">
<h1 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 via-orange-400 to-accent-400">
Требуется Telegram
</h1>
<div className="absolute inset-0 text-3xl font-bold text-amber-500/20 blur-xl">
Требуется Telegram
</div>
</div>
<p className="text-gray-400 mb-2 max-w-md">
Для доступа к админ-панели необходимо привязать Telegram-аккаунт.
</p>
<p className="text-gray-500 text-sm mb-8 max-w-md">
Это требуется для двухфакторной аутентификации при входе.
</p>
{/* Info card */}
<div className="glass rounded-xl p-4 mb-8 max-w-md border border-amber-500/20">
<div className="flex items-center gap-2 text-amber-400 mb-2">
<Shield className="w-4 h-4" />
<span className="text-sm font-semibold">Двухфакторная аутентификация</span>
</div>
<p className="text-gray-400 text-sm">
После привязки Telegram при входе в админ-панель вам будет отправляться код подтверждения.
</p>
</div>
{/* Buttons */}
<div className="flex gap-4">
<Link to="/profile">
<NeonButton size="lg" color="purple" icon={<MessageCircle className="w-5 h-5" />}>
Привязать Telegram
</NeonButton>
</Link>
<Link to="/marathons">
<NeonButton size="lg" variant="secondary" icon={<ArrowLeft className="w-5 h-5" />}>
На сайт
</NeonButton>
</Link>
</div>
{/* Decorative sparkles */}
<div className="absolute top-1/4 left-1/4 opacity-20">
<Sparkles className="w-6 h-6 text-amber-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>
)
}
return (
<div className="flex h-full min-h-[calc(100vh-64px)]">
{/* Background effects */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-0 -left-32 w-96 h-96 bg-accent-500/5 rounded-full blur-[100px]" />
<div className="absolute bottom-0 -right-32 w-96 h-96 bg-neon-500/5 rounded-full blur-[100px]" />
</div>
{/* Sidebar */}
<aside className="w-64 glass border-r border-dark-600 flex flex-col relative z-10">
<div className="p-4 border-b border-dark-600">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-accent-500 to-pink-500 flex items-center justify-center">
<Shield className="w-4 h-4 text-white" />
</div>
<h2 className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-accent-400 to-pink-400">
Админ-панель
</h2>
</div>
</div>
<nav className="flex-1 p-4 space-y-1">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 ${
isActive
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30 shadow-[0_0_10px_rgba(139,92,246,0.15)]'
: 'text-gray-400 hover:bg-dark-600/50 hover:text-white border border-transparent'
}`
}
>
<item.icon className="w-5 h-5" />
<span className="font-medium">{item.label}</span>
</NavLink>
))}
</nav>
<div className="p-4 border-t border-dark-600">
<NavLink
to="/marathons"
className="flex items-center gap-3 px-3 py-2.5 text-gray-400 hover:text-neon-400 transition-colors rounded-lg hover:bg-dark-600/50"
>
<ArrowLeft className="w-5 h-5" />
<span className="font-medium">Вернуться на сайт</span>
</NavLink>
</div>
</aside>
{/* Main content */}
<main className="flex-1 p-6 overflow-auto relative z-10">
<Outlet />
</main>
</div>
)
}

View File

@@ -0,0 +1,208 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from '@/api'
import type { AdminLog } from '@/types'
import { useToast } from '@/store/toast'
import { ChevronLeft, ChevronRight, Filter, ScrollText } from 'lucide-react'
const ACTION_LABELS: Record<string, string> = {
user_ban: 'Бан пользователя',
user_unban: 'Разбан пользователя',
user_auto_unban: 'Авто-разбан (система)',
user_role_change: 'Изменение роли',
marathon_force_finish: 'Принудительное завершение',
marathon_delete: 'Удаление марафона',
content_update: 'Обновление контента',
broadcast_all: 'Рассылка всем',
broadcast_marathon: 'Рассылка марафону',
admin_login: 'Вход админа',
admin_2fa_success: '2FA успех',
admin_2fa_fail: '2FA неудача',
}
const ACTION_COLORS: Record<string, string> = {
user_ban: 'bg-red-500/20 text-red-400 border border-red-500/30',
user_unban: 'bg-green-500/20 text-green-400 border border-green-500/30',
user_auto_unban: 'bg-cyan-500/20 text-cyan-400 border border-cyan-500/30',
user_role_change: 'bg-accent-500/20 text-accent-400 border border-accent-500/30',
marathon_force_finish: 'bg-orange-500/20 text-orange-400 border border-orange-500/30',
marathon_delete: 'bg-red-500/20 text-red-400 border border-red-500/30',
content_update: 'bg-neon-500/20 text-neon-400 border border-neon-500/30',
broadcast_all: 'bg-pink-500/20 text-pink-400 border border-pink-500/30',
broadcast_marathon: 'bg-pink-500/20 text-pink-400 border border-pink-500/30',
admin_login: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
admin_2fa_success: 'bg-green-500/20 text-green-400 border border-green-500/30',
admin_2fa_fail: 'bg-red-500/20 text-red-400 border border-red-500/30',
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
export function AdminLogsPage() {
const [logs, setLogs] = useState<AdminLog[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [actionFilter, setActionFilter] = useState<string>('')
const [page, setPage] = useState(0)
const toast = useToast()
const LIMIT = 30
const loadLogs = useCallback(async () => {
setLoading(true)
try {
const data = await adminApi.getLogs(page * LIMIT, LIMIT, actionFilter || undefined)
setLogs(data.logs)
setTotal(data.total)
} catch (err) {
console.error('Failed to load logs:', err)
toast.error('Ошибка загрузки логов')
} finally {
setLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, actionFilter])
useEffect(() => {
loadLogs()
}, [loadLogs])
const totalPages = Math.ceil(total / LIMIT)
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-orange-500/20 border border-orange-500/30">
<ScrollText className="w-6 h-6 text-orange-400" />
</div>
<h1 className="text-2xl font-bold text-white">Логи действий</h1>
</div>
<span className="text-sm text-gray-400 bg-dark-700/50 px-3 py-1.5 rounded-lg border border-dark-600">
Всего: <span className="text-white font-medium">{total}</span> записей
</span>
</div>
{/* Filters */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
<Filter className="w-5 h-5 text-gray-500" />
<select
value={actionFilter}
onChange={(e) => {
setActionFilter(e.target.value)
setPage(0)
}}
className="bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white focus:outline-none focus:border-accent-500/50 transition-colors min-w-[200px]"
>
<option value="">Все действия</option>
{Object.entries(ACTION_LABELS).map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
</div>
{/* Logs Table */}
<div className="glass rounded-xl border border-dark-600 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-dark-700/50 border-b border-dark-600">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Дата</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Админ</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Действие</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Цель</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Детали</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">IP</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-600">
{loading ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
</td>
</tr>
) : logs.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
Логи не найдены
</td>
</tr>
) : (
logs.map((log) => (
<tr key={log.id} className="hover:bg-dark-700/30 transition-colors">
<td className="px-4 py-3 text-sm text-gray-400 whitespace-nowrap font-mono">
{formatDate(log.created_at)}
</td>
<td className="px-4 py-3 text-sm font-medium">
{log.admin_nickname ? (
<span className="text-white">{log.admin_nickname}</span>
) : (
<span className="text-cyan-400 italic">Система</span>
)}
</td>
<td className="px-4 py-3">
<span className={`inline-flex px-2.5 py-1 text-xs font-medium rounded-lg ${ACTION_COLORS[log.action] || 'bg-dark-600/50 text-gray-400 border border-dark-500'}`}>
{ACTION_LABELS[log.action] || log.action}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-400">
<span className="text-gray-500">{log.target_type}</span>
<span className="text-neon-400 font-mono ml-1">#{log.target_id}</span>
</td>
<td className="px-4 py-3 text-sm text-gray-500 max-w-xs">
{log.details ? (
<span className="font-mono text-xs bg-dark-700/50 px-2 py-1 rounded truncate block">
{JSON.stringify(log.details)}
</span>
) : (
<span className="text-gray-600"></span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-500 font-mono">
{log.ip_address || '—'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-3 border-t border-dark-600">
<button
onClick={() => setPage(Math.max(0, page - 1))}
disabled={page === 0}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
>
<ChevronLeft className="w-4 h-4" />
Назад
</button>
<span className="text-sm text-gray-500">
Страница <span className="text-white font-medium">{page + 1}</span> из <span className="text-white font-medium">{totalPages || 1}</span>
</span>
<button
onClick={() => setPage(page + 1)}
disabled={page >= totalPages - 1}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
>
Вперед
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,242 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from '@/api'
import type { AdminMarathon } from '@/types'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
import { NeonButton } from '@/components/ui'
import { Search, Trash2, StopCircle, ChevronLeft, ChevronRight, Trophy, Clock, CheckCircle, Loader2 } from 'lucide-react'
const STATUS_CONFIG: Record<string, { label: string; icon: typeof Clock; className: string }> = {
preparing: {
label: 'Подготовка',
icon: Loader2,
className: 'bg-amber-500/20 text-amber-400 border border-amber-500/30'
},
active: {
label: 'Активный',
icon: Clock,
className: 'bg-green-500/20 text-green-400 border border-green-500/30'
},
finished: {
label: 'Завершён',
icon: CheckCircle,
className: 'bg-dark-600/50 text-gray-400 border border-dark-500'
},
}
function formatDate(dateStr: string | null) {
if (!dateStr) return '—'
return new Date(dateStr).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
export function AdminMarathonsPage() {
const [marathons, setMarathons] = useState<AdminMarathon[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [page, setPage] = useState(0)
const toast = useToast()
const confirm = useConfirm()
const LIMIT = 20
const loadMarathons = useCallback(async () => {
setLoading(true)
try {
const data = await adminApi.listMarathons(page * LIMIT, LIMIT, search || undefined)
setMarathons(data)
} catch (err) {
console.error('Failed to load marathons:', err)
toast.error('Ошибка загрузки марафонов')
} finally {
setLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, search])
useEffect(() => {
loadMarathons()
}, [loadMarathons])
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
setPage(0)
loadMarathons()
}
const handleDelete = async (marathon: AdminMarathon) => {
const confirmed = await confirm({
title: 'Удалить марафон',
message: `Вы уверены, что хотите удалить марафон "${marathon.title}"? Это действие необратимо.`,
confirmText: 'Удалить',
variant: 'danger',
})
if (!confirmed) return
try {
await adminApi.deleteMarathon(marathon.id)
setMarathons(marathons.filter(m => m.id !== marathon.id))
toast.success('Марафон удалён')
} catch (err) {
console.error('Failed to delete marathon:', err)
toast.error('Ошибка удаления')
}
}
const handleForceFinish = async (marathon: AdminMarathon) => {
const confirmed = await confirm({
title: 'Завершить марафон',
message: `Принудительно завершить марафон "${marathon.title}"? Участники получат уведомление.`,
confirmText: 'Завершить',
variant: 'warning',
})
if (!confirmed) return
try {
await adminApi.forceFinishMarathon(marathon.id)
setMarathons(marathons.map(m =>
m.id === marathon.id ? { ...m, status: 'finished' } : m
))
toast.success('Марафон завершён')
} catch (err) {
console.error('Failed to finish marathon:', err)
toast.error('Ошибка завершения')
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-accent-500/20 border border-accent-500/30">
<Trophy className="w-6 h-6 text-accent-400" />
</div>
<h1 className="text-2xl font-bold text-white">Марафоны</h1>
</div>
{/* Search */}
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<input
type="text"
placeholder="Поиск по названию..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl pl-10 pr-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/20 transition-all"
/>
</div>
<NeonButton type="submit" color="purple">
Найти
</NeonButton>
</form>
{/* Marathons Table */}
<div className="glass rounded-xl border border-dark-600 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-dark-700/50 border-b border-dark-600">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">ID</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Название</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Создатель</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Участники</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Игры</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Даты</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Действия</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-600">
{loading ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
</td>
</tr>
) : marathons.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
Марафоны не найдены
</td>
</tr>
) : (
marathons.map((marathon) => {
const statusConfig = STATUS_CONFIG[marathon.status] || STATUS_CONFIG.finished
const StatusIcon = statusConfig.icon
return (
<tr key={marathon.id} className="hover:bg-dark-700/30 transition-colors">
<td className="px-4 py-3 text-sm text-gray-400 font-mono">{marathon.id}</td>
<td className="px-4 py-3 text-sm text-white font-medium">{marathon.title}</td>
<td className="px-4 py-3 text-sm text-gray-300">{marathon.creator.nickname}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg ${statusConfig.className}`}>
<StatusIcon className={`w-3 h-3 ${marathon.status === 'preparing' ? 'animate-spin' : ''}`} />
{statusConfig.label}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-400">{marathon.participants_count}</td>
<td className="px-4 py-3 text-sm text-gray-400">{marathon.games_count}</td>
<td className="px-4 py-3 text-sm text-gray-400">
<span className="text-gray-500">{formatDate(marathon.start_date)}</span>
<span className="text-gray-600 mx-1"></span>
<span className="text-gray-500">{formatDate(marathon.end_date)}</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
{marathon.status !== 'finished' && (
<button
onClick={() => handleForceFinish(marathon)}
className="p-2 text-orange-400 hover:bg-orange-500/20 rounded-lg transition-colors"
title="Завершить марафон"
>
<StopCircle className="w-4 h-4" />
</button>
)}
<button
onClick={() => handleDelete(marathon)}
className="p-2 text-red-400 hover:bg-red-500/20 rounded-lg transition-colors"
title="Удалить"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-3 border-t border-dark-600">
<button
onClick={() => setPage(Math.max(0, page - 1))}
disabled={page === 0}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
>
<ChevronLeft className="w-4 h-4" />
Назад
</button>
<span className="text-sm text-gray-500">
Страница <span className="text-white font-medium">{page + 1}</span>
</span>
<button
onClick={() => setPage(page + 1)}
disabled={marathons.length < LIMIT}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
>
Вперед
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,398 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from '@/api'
import type { AdminUser, UserRole } from '@/types'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
import { NeonButton } from '@/components/ui'
import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X } from 'lucide-react'
export function AdminUsersPage() {
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [bannedOnly, setBannedOnly] = useState(false)
const [page, setPage] = useState(0)
const [banModalUser, setBanModalUser] = useState<AdminUser | null>(null)
const [banReason, setBanReason] = useState('')
const [banDuration, setBanDuration] = useState<string>('permanent')
const [banCustomDate, setBanCustomDate] = useState('')
const [banning, setBanning] = useState(false)
const toast = useToast()
const confirm = useConfirm()
const LIMIT = 20
const loadUsers = useCallback(async () => {
setLoading(true)
try {
const data = await adminApi.listUsers(page * LIMIT, LIMIT, search || undefined, bannedOnly)
setUsers(data)
} catch (err) {
console.error('Failed to load users:', err)
toast.error('Ошибка загрузки пользователей')
} finally {
setLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, search, bannedOnly])
useEffect(() => {
loadUsers()
}, [loadUsers])
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
setPage(0)
loadUsers()
}
const handleBan = async () => {
if (!banModalUser || !banReason.trim()) return
let bannedUntil: string | undefined
if (banDuration !== 'permanent') {
const now = new Date()
if (banDuration === '1d') {
now.setDate(now.getDate() + 1)
bannedUntil = now.toISOString()
} else if (banDuration === '7d') {
now.setDate(now.getDate() + 7)
bannedUntil = now.toISOString()
} else if (banDuration === '30d') {
now.setDate(now.getDate() + 30)
bannedUntil = now.toISOString()
} else if (banDuration === 'custom' && banCustomDate) {
bannedUntil = new Date(banCustomDate).toISOString()
}
}
setBanning(true)
try {
const updated = await adminApi.banUser(banModalUser.id, banReason, bannedUntil)
setUsers(users.map(u => u.id === updated.id ? updated : u))
toast.success(`Пользователь ${updated.nickname} заблокирован`)
setBanModalUser(null)
setBanReason('')
setBanDuration('permanent')
setBanCustomDate('')
} catch (err) {
console.error('Failed to ban user:', err)
toast.error('Ошибка блокировки')
} finally {
setBanning(false)
}
}
const handleUnban = async (user: AdminUser) => {
const confirmed = await confirm({
title: 'Разблокировать пользователя',
message: `Вы уверены, что хотите разблокировать ${user.nickname}?`,
confirmText: 'Разблокировать',
})
if (!confirmed) return
try {
const updated = await adminApi.unbanUser(user.id)
setUsers(users.map(u => u.id === updated.id ? updated : u))
toast.success(`Пользователь ${updated.nickname} разблокирован`)
} catch (err) {
console.error('Failed to unban user:', err)
toast.error('Ошибка разблокировки')
}
}
const handleRoleChange = async (user: AdminUser, newRole: UserRole) => {
const confirmed = await confirm({
title: 'Изменить роль',
message: `Изменить роль ${user.nickname} на ${newRole === 'admin' ? 'Администратор' : 'Пользователь'}?`,
confirmText: 'Изменить',
})
if (!confirmed) return
try {
const updated = await adminApi.setUserRole(user.id, newRole)
setUsers(users.map(u => u.id === updated.id ? updated : u))
toast.success(`Роль ${updated.nickname} изменена`)
} catch (err) {
console.error('Failed to change role:', err)
toast.error('Ошибка изменения роли')
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-500/20 border border-blue-500/30">
<Users className="w-6 h-6 text-blue-400" />
</div>
<h1 className="text-2xl font-bold text-white">Пользователи</h1>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<form onSubmit={handleSearch} className="flex-1 flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<input
type="text"
placeholder="Поиск по логину или никнейму..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl pl-10 pr-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/20 transition-all"
/>
</div>
<NeonButton type="submit" color="purple">
Найти
</NeonButton>
</form>
<label className="flex items-center gap-2 text-gray-300 cursor-pointer group">
<input
type="checkbox"
checked={bannedOnly}
onChange={(e) => {
setBannedOnly(e.target.checked)
setPage(0)
}}
className="w-4 h-4 rounded border-dark-600 bg-dark-700 text-accent-500 focus:ring-accent-500/50 focus:ring-offset-0"
/>
<span className="group-hover:text-white transition-colors">Только заблокированные</span>
</label>
</div>
{/* Users Table */}
<div className="glass rounded-xl border border-dark-600 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-dark-700/50 border-b border-dark-600">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">ID</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Логин</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Никнейм</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Роль</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Telegram</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Марафоны</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Действия</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-600">
{loading ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
</td>
</tr>
) : users.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
Пользователи не найдены
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id} className="hover:bg-dark-700/30 transition-colors">
<td className="px-4 py-3 text-sm text-gray-400 font-mono">{user.id}</td>
<td className="px-4 py-3 text-sm text-white">{user.login}</td>
<td className="px-4 py-3 text-sm text-white font-medium">{user.nickname}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg ${
user.role === 'admin'
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30'
: 'bg-dark-600/50 text-gray-400 border border-dark-500'
}`}>
{user.role === 'admin' && <Shield className="w-3 h-3" />}
{user.role === 'admin' ? 'Админ' : 'Пользователь'}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-400">
{user.telegram_username ? (
<span className="text-neon-400">@{user.telegram_username}</span>
) : (
<span className="text-gray-600"></span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-400">{user.marathons_count}</td>
<td className="px-4 py-3">
{user.is_banned ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-red-500/20 text-red-400 border border-red-500/30">
<Ban className="w-3 h-3" />
Заблокирован
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-green-500/20 text-green-400 border border-green-500/30">
<UserCheck className="w-3 h-3" />
Активен
</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
{user.is_banned ? (
<button
onClick={() => handleUnban(user)}
className="p-2 text-green-400 hover:bg-green-500/20 rounded-lg transition-colors"
title="Разблокировать"
>
<UserCheck className="w-4 h-4" />
</button>
) : user.role !== 'admin' ? (
<button
onClick={() => setBanModalUser(user)}
className="p-2 text-red-400 hover:bg-red-500/20 rounded-lg transition-colors"
title="Заблокировать"
>
<Ban className="w-4 h-4" />
</button>
) : null}
{user.role === 'admin' ? (
<button
onClick={() => handleRoleChange(user, 'user')}
className="p-2 text-orange-400 hover:bg-orange-500/20 rounded-lg transition-colors"
title="Снять права админа"
>
<ShieldOff className="w-4 h-4" />
</button>
) : (
<button
onClick={() => handleRoleChange(user, 'admin')}
className="p-2 text-accent-400 hover:bg-accent-500/20 rounded-lg transition-colors"
title="Сделать админом"
>
<Shield className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-3 border-t border-dark-600">
<button
onClick={() => setPage(Math.max(0, page - 1))}
disabled={page === 0}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
>
<ChevronLeft className="w-4 h-4" />
Назад
</button>
<span className="text-sm text-gray-500">
Страница <span className="text-white font-medium">{page + 1}</span>
</span>
<button
onClick={() => setPage(page + 1)}
disabled={users.length < LIMIT}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
>
Вперед
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
{/* Ban Modal */}
{banModalUser && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
<div className="glass rounded-2xl p-6 max-w-md w-full mx-4 border border-dark-600 shadow-2xl">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Ban className="w-5 h-5 text-red-400" />
Заблокировать {banModalUser.nickname}?
</h3>
<button
onClick={() => {
setBanModalUser(null)
setBanReason('')
setBanDuration('permanent')
setBanCustomDate('')
}}
className="p-1.5 text-gray-400 hover:text-white rounded-lg hover:bg-dark-600/50 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Ban Duration */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">
Срок блокировки
</label>
<select
value={banDuration}
onChange={(e) => setBanDuration(e.target.value)}
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white focus:outline-none focus:border-accent-500/50 transition-colors"
>
<option value="permanent">Навсегда</option>
<option value="1d">1 день</option>
<option value="7d">7 дней</option>
<option value="30d">30 дней</option>
<option value="custom">Указать дату</option>
</select>
</div>
{/* Custom Date */}
{banDuration === 'custom' && (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">
Разблокировать
</label>
<input
type="datetime-local"
value={banCustomDate}
onChange={(e) => setBanCustomDate(e.target.value)}
min={new Date().toISOString().slice(0, 16)}
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white focus:outline-none focus:border-accent-500/50 transition-colors"
/>
</div>
)}
{/* Reason */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-300 mb-2">
Причина
</label>
<textarea
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
placeholder="Причина блокировки..."
rows={3}
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors resize-none"
/>
</div>
<div className="flex gap-3 justify-end">
<NeonButton
variant="ghost"
onClick={() => {
setBanModalUser(null)
setBanReason('')
setBanDuration('permanent')
setBanCustomDate('')
}}
>
Отмена
</NeonButton>
<NeonButton
variant="danger"
onClick={handleBan}
disabled={!banReason.trim() || banning || (banDuration === 'custom' && !banCustomDate)}
isLoading={banning}
icon={<Ban className="w-4 h-4" />}
>
Заблокировать
</NeonButton>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,7 @@
export { AdminLayout } from './AdminLayout'
export { AdminDashboardPage } from './AdminDashboardPage'
export { AdminUsersPage } from './AdminUsersPage'
export { AdminMarathonsPage } from './AdminMarathonsPage'
export { AdminLogsPage } from './AdminLogsPage'
export { AdminBroadcastPage } from './AdminBroadcastPage'
export { AdminContentPage } from './AdminContentPage'

View File

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

View File

@@ -3,6 +3,21 @@ import { persist } from 'zustand/middleware'
import type { User } from '@/types' import type { User } from '@/types'
import { authApi, type RegisterData, type LoginData } from '@/api/auth' import { authApi, type RegisterData, type LoginData } from '@/api/auth'
interface Pending2FA {
sessionId: number
}
interface LoginResult {
requires2FA: boolean
sessionId?: number
}
export interface BanInfo {
banned_at: string | null
banned_until: string | null
reason: string | null
}
interface AuthState { interface AuthState {
user: User | null user: User | null
token: string | null token: string | null
@@ -10,14 +25,22 @@ interface AuthState {
isLoading: boolean isLoading: boolean
error: string | null error: string | null
pendingInviteCode: string | null pendingInviteCode: string | null
avatarVersion: number
pending2FA: Pending2FA | null
banInfo: BanInfo | null
login: (data: LoginData) => Promise<void> login: (data: LoginData) => Promise<LoginResult>
verify2FA: (code: string) => Promise<void>
cancel2FA: () => void
register: (data: RegisterData) => Promise<void> register: (data: RegisterData) => Promise<void>
logout: () => void logout: () => void
clearError: () => void clearError: () => void
setPendingInviteCode: (code: string | null) => void setPendingInviteCode: (code: string | null) => void
consumePendingInviteCode: () => string | null consumePendingInviteCode: () => string | null
updateUser: (updates: Partial<User>) => void updateUser: (updates: Partial<User>) => void
bumpAvatarVersion: () => void
setBanned: (banInfo: BanInfo) => void
clearBanned: () => void
} }
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
@@ -29,11 +52,26 @@ export const useAuthStore = create<AuthState>()(
isLoading: false, isLoading: false,
error: null, error: null,
pendingInviteCode: null, pendingInviteCode: null,
avatarVersion: 0,
pending2FA: null,
banInfo: null,
login: async (data) => { login: async (data) => {
set({ isLoading: true, error: null }) set({ isLoading: true, error: null, pending2FA: null })
try { try {
const response = await authApi.login(data) const response = await authApi.login(data)
// Check if 2FA is required
if (response.requires_2fa && response.two_factor_session_id) {
set({
isLoading: false,
pending2FA: { sessionId: response.two_factor_session_id },
})
return { requires2FA: true, sessionId: response.two_factor_session_id }
}
// Regular login (no 2FA)
if (response.access_token && response.user) {
localStorage.setItem('token', response.access_token) localStorage.setItem('token', response.access_token)
set({ set({
user: response.user, user: response.user,
@@ -41,6 +79,8 @@ export const useAuthStore = create<AuthState>()(
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
}) })
}
return { requires2FA: false }
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
set({ set({
@@ -51,6 +91,37 @@ export const useAuthStore = create<AuthState>()(
} }
}, },
verify2FA: async (code) => {
const pending = get().pending2FA
if (!pending) {
throw new Error('No pending 2FA session')
}
set({ isLoading: true, error: null })
try {
const response = await authApi.verify2FA(pending.sessionId, code)
localStorage.setItem('token', response.access_token)
set({
user: response.user,
token: response.access_token,
isAuthenticated: true,
isLoading: false,
pending2FA: null,
})
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
set({
error: error.response?.data?.detail || '2FA verification failed',
isLoading: false,
})
throw err
}
},
cancel2FA: () => {
set({ pending2FA: null, error: null })
},
register: async (data) => { register: async (data) => {
set({ isLoading: true, error: null }) set({ isLoading: true, error: null })
try { try {
@@ -78,6 +149,7 @@ export const useAuthStore = create<AuthState>()(
user: null, user: null,
token: null, token: null,
isAuthenticated: false, isAuthenticated: false,
banInfo: null,
}) })
}, },
@@ -97,6 +169,18 @@ export const useAuthStore = create<AuthState>()(
set({ user: { ...currentUser, ...updates } }) set({ user: { ...currentUser, ...updates } })
} }
}, },
bumpAvatarVersion: () => {
set({ avatarVersion: get().avatarVersion + 1 })
},
setBanned: (banInfo) => {
set({ banInfo })
},
clearBanned: () => {
set({ banInfo: null })
},
}), }),
{ {
name: 'auth-storage', name: 'auth-storage',

View File

@@ -1,26 +1,40 @@
// User types // User types
export type UserRole = 'user' | 'admin' export type UserRole = 'user' | 'admin'
export interface User { // Public user info (visible to other users)
export interface UserPublic {
id: number id: number
login: string
nickname: string nickname: string
avatar_url: string | null avatar_url: string | null
role: UserRole role: UserRole
telegram_id: number | null
telegram_username: string | null
telegram_first_name: string | null
telegram_last_name: string | null
telegram_avatar_url: string | null telegram_avatar_url: string | null
created_at: string created_at: string
} }
// Full user info (only for own profile from /auth/me)
export interface User extends UserPublic {
login?: string // Only visible to self
telegram_id?: number | null // Only visible to self
telegram_username?: string | null // Only visible to self
telegram_first_name?: string | null // Only visible to self
telegram_last_name?: string | null // Only visible to self
}
export interface TokenResponse { export interface TokenResponse {
access_token: string access_token: string
token_type: string token_type: string
user: User user: User
} }
// Login response (may require 2FA for admins)
export interface LoginResponse {
access_token?: string | null
token_type: string
user?: User | null
requires_2fa: boolean
two_factor_session_id?: number | null
}
// Marathon types // Marathon types
export type MarathonStatus = 'preparing' | 'active' | 'finished' export type MarathonStatus = 'preparing' | 'active' | 'finished'
export type ParticipantRole = 'participant' | 'organizer' export type ParticipantRole = 'participant' | 'organizer'
@@ -130,6 +144,13 @@ export type ChallengeType =
export type Difficulty = 'easy' | 'medium' | 'hard' export type Difficulty = 'easy' | 'medium' | 'hard'
export type ProofType = 'screenshot' | 'video' | 'steam' export type ProofType = 'screenshot' | 'video' | 'steam'
export type ChallengeStatus = 'pending' | 'approved' | 'rejected'
export interface ProposedByUser {
id: number
nickname: string
}
export interface Challenge { export interface Challenge {
id: number id: number
game: GameShort game: GameShort
@@ -143,6 +164,8 @@ export interface Challenge {
proof_hint: string | null proof_hint: string | null
is_generated: boolean is_generated: boolean
created_at: string created_at: string
status: ChallengeStatus
proposed_by: ProposedByUser | null
} }
export interface ChallengePreview { export interface ChallengePreview {
@@ -390,6 +413,10 @@ export interface AdminUser {
telegram_username: string | null telegram_username: string | null
marathons_count: number marathons_count: number
created_at: string created_at: string
is_banned: boolean
banned_at: string | null
banned_until: string | null // null = permanent ban
ban_reason: string | null
} }
export interface AdminMarathon { export interface AdminMarathon {
@@ -411,6 +438,64 @@ export interface PlatformStats {
total_participations: number total_participations: number
} }
// Admin action log types
export type AdminActionType =
| 'user_ban'
| 'user_unban'
| 'user_role_change'
| 'marathon_force_finish'
| 'marathon_delete'
| 'content_update'
| 'broadcast_all'
| 'broadcast_marathon'
| 'admin_login'
| 'admin_2fa_success'
| 'admin_2fa_fail'
export interface AdminLog {
id: number
admin_id: number
admin_nickname: string
action: AdminActionType
target_type: string
target_id: number
details: Record<string, unknown> | null
ip_address: string | null
created_at: string
}
export interface AdminLogsResponse {
logs: AdminLog[]
total: number
}
// Broadcast types
export interface BroadcastResponse {
sent_count: number
total_count: number
}
// Static content types
export interface StaticContent {
id: number
key: string
title: string
content: string
updated_at: string
created_at: string
}
// Dashboard stats
export interface DashboardStats {
users_count: number
banned_users_count: number
marathons_count: number
active_marathons_count: number
games_count: number
total_participations: number
recent_logs: AdminLog[]
}
// Dispute types // Dispute types
export type DisputeStatus = 'open' | 'valid' | 'invalid' export type DisputeStatus = 'open' | 'valid' | 'invalid'

View File

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

View File

@@ -17,6 +17,10 @@ http {
# File upload limit (15 MB) # File upload limit (15 MB)
client_max_body_size 15M; client_max_body_size 15M;
# Rate limiting zones
limit_req_zone $binary_remote_addr zone=api_auth:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=api_general:10m rate=60r/m;
upstream backend { upstream backend {
server backend:8000; server backend:8000;
} }
@@ -37,17 +41,31 @@ http {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
} }
# Backend API # Auth API - strict rate limit (10 req/min with burst of 5)
location /api/v1/auth {
limit_req zone=api_auth burst=5 nodelay;
limit_req_status 429;
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Backend API - general rate limit (60 req/min with burst of 20)
location /api { location /api {
limit_req zone=api_general burst=20 nodelay;
limit_req_status 429;
proxy_pass http://backend; proxy_pass http://backend;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Timeout for file uploads # Timeout for long GPT requests (15 min)
proxy_read_timeout 300; proxy_read_timeout 900;
proxy_connect_timeout 300; proxy_connect_timeout 900;
proxy_send_timeout 300; proxy_send_timeout 900;
} }
# Static files (uploads) # Static files (uploads)

16
status-service/Dockerfile Normal file
View File

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

Some files were not shown because too many files have changed in this diff Show More