Compare commits

31 Commits

Author SHA1 Message Date
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
895e296f44 Fixes 2025-12-16 22:43:03 +07:00
696dc714c4 Update GPT and add Profile 2025-12-16 22:12:12 +07:00
08b96fd1f7 Fix marathon deletion 2025-12-16 21:15:18 +07:00
ca41c207b3 Add info if linked acc 2025-12-16 20:59:50 +07:00
412de3bf05 Add telegram bot 2025-12-16 20:06:16 +07:00
9fd93a185c Improved prompt for GPT 2025-12-16 03:53:53 +07:00
fe6012b7a3 Add manual add for challanges 2025-12-16 03:27:57 +07:00
a199952383 Change points balance 2025-12-16 03:06:26 +07:00
e32df4d95e Fix dispute 2025-12-16 02:35:59 +07:00
f57a2ba9ea Add marathon finish button and system 2025-12-16 02:22:12 +07:00
d96f8de568 Add limits for content + fix video playback 2025-12-16 02:01:03 +07:00
574140e67d Add modals 2025-12-16 01:50:40 +07:00
87ecd9756c Moved to S3 2025-12-16 01:33:29 +07:00
c7966656d8 Add dispute system 2025-12-16 00:33:50 +07:00
339a212e57 Change rematch event to change game 2025-12-15 23:50:37 +07:00
07e02ce32d Common enemy rework 2025-12-15 23:03:59 +07:00
9a037cb34f Add events history 2025-12-15 22:31:42 +07:00
124 changed files with 15495 additions and 2156 deletions

41
.dockerignore Normal file
View File

@@ -0,0 +1,41 @@
# Dependencies
node_modules
*/node_modules
# Build outputs
dist
build
*.pyc
__pycache__
# Git
.git
.gitignore
# IDE
.idea
.vscode
*.swp
*.swo
# Logs
*.log
npm-debug.log*
# Environment files (keep .env.example)
.env
.env.local
.env.*.local
# OS files
.DS_Store
Thumbs.db
# Test & coverage
coverage
.pytest_cache
.coverage
# Misc
*.md
!README.md

View File

@@ -10,6 +10,25 @@ 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_ENABLED=false
S3_BUCKET_NAME=your-bucket-name
S3_REGION=ru-1
S3_ACCESS_KEY_ID=your-access-key-id
S3_SECRET_ACCESS_KEY=your-secret-access-key
S3_ENDPOINT_URL=https://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,54 @@
"""Add is_event_assignment and event_id to assignments for Common Enemy support
Revision ID: 007_add_event_assignment_fields
Revises: 006_add_swap_requests
Create Date: 2024-12-15
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '007_add_event_assignment_fields'
down_revision: Union[str, None] = '006_add_swap_requests'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add is_event_assignment column with default False
conn = op.get_bind()
inspector = sa.inspect(conn)
columns = [col['name'] for col in inspector.get_columns('assignments')]
if 'is_event_assignment' not in columns:
op.add_column(
'assignments',
sa.Column('is_event_assignment', sa.Boolean(), nullable=False, server_default=sa.false())
)
op.create_index('ix_assignments_is_event_assignment', 'assignments', ['is_event_assignment'])
if 'event_id' not in columns:
op.add_column(
'assignments',
sa.Column('event_id', sa.Integer(), nullable=True)
)
op.create_foreign_key(
'fk_assignments_event_id',
'assignments',
'events',
['event_id'],
['id'],
ondelete='SET NULL'
)
op.create_index('ix_assignments_event_id', 'assignments', ['event_id'])
def downgrade() -> None:
op.drop_index('ix_assignments_event_id', table_name='assignments')
op.drop_constraint('fk_assignments_event_id', 'assignments', type_='foreignkey')
op.drop_column('assignments', 'event_id')
op.drop_index('ix_assignments_is_event_assignment', table_name='assignments')
op.drop_column('assignments', 'is_event_assignment')

View File

@@ -0,0 +1,41 @@
"""Rename rematch event type to game_choice
Revision ID: 008_rename_to_game_choice
Revises: 007_add_event_assignment_fields
Create Date: 2024-12-15
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "008_rename_to_game_choice"
down_revision = "007_add_event_assignment_fields"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Update event type from 'rematch' to 'game_choice' in events table
op.execute("UPDATE events SET type = 'game_choice' WHERE type = 'rematch'")
# Update event_type in assignments table
op.execute("UPDATE assignments SET event_type = 'game_choice' WHERE event_type = 'rematch'")
# Update activity data that references rematch event
op.execute("""
UPDATE activities
SET data = jsonb_set(data, '{event_type}', '"game_choice"')
WHERE data->>'event_type' = 'rematch'
""")
def downgrade() -> None:
# Revert event type from 'game_choice' to 'rematch'
op.execute("UPDATE events SET type = 'rematch' WHERE type = 'game_choice'")
op.execute("UPDATE assignments SET event_type = 'rematch' WHERE event_type = 'game_choice'")
op.execute("""
UPDATE activities
SET data = jsonb_set(data, '{event_type}', '"rematch"')
WHERE data->>'event_type' = 'game_choice'
""")

View File

@@ -0,0 +1,81 @@
"""Add disputes tables for proof verification system
Revision ID: 009_add_disputes
Revises: 008_rename_to_game_choice
Create Date: 2024-12-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '009_add_disputes'
down_revision: Union[str, None] = '008_rename_to_game_choice'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
tables = inspector.get_table_names()
# Create disputes table
if 'disputes' not in tables:
op.create_table(
'disputes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('assignment_id', sa.Integer(), nullable=False),
sa.Column('raised_by_id', sa.Integer(), nullable=False),
sa.Column('reason', sa.Text(), nullable=False),
sa.Column('status', sa.String(20), nullable=False, server_default='open'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('resolved_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['assignment_id'], ['assignments.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['raised_by_id'], ['users.id'], ondelete='CASCADE'),
sa.UniqueConstraint('assignment_id', name='uq_dispute_assignment'),
)
op.create_index('ix_disputes_assignment_id', 'disputes', ['assignment_id'])
# Create dispute_comments table
if 'dispute_comments' not in tables:
op.create_table(
'dispute_comments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('dispute_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('text', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['dispute_id'], ['disputes.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
)
op.create_index('ix_dispute_comments_dispute_id', 'dispute_comments', ['dispute_id'])
# Create dispute_votes table
if 'dispute_votes' not in tables:
op.create_table(
'dispute_votes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('dispute_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('vote', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['dispute_id'], ['disputes.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.UniqueConstraint('dispute_id', 'user_id', name='uq_dispute_vote_user'),
)
op.create_index('ix_dispute_votes_dispute_id', 'dispute_votes', ['dispute_id'])
def downgrade() -> None:
op.drop_index('ix_dispute_votes_dispute_id', table_name='dispute_votes')
op.drop_table('dispute_votes')
op.drop_index('ix_dispute_comments_dispute_id', table_name='dispute_comments')
op.drop_table('dispute_comments')
op.drop_index('ix_disputes_assignment_id', table_name='disputes')
op.drop_table('disputes')

View File

@@ -0,0 +1,30 @@
"""Add telegram profile fields to users
Revision ID: 010_add_telegram_profile
Revises: 009_add_disputes
Create Date: 2024-12-16
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '010_add_telegram_profile'
down_revision: Union[str, None] = '009_add_disputes'
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('telegram_first_name', sa.String(100), nullable=True))
op.add_column('users', sa.Column('telegram_last_name', sa.String(100), nullable=True))
op.add_column('users', sa.Column('telegram_avatar_url', sa.String(500), nullable=True))
def downgrade() -> None:
op.drop_column('users', 'telegram_avatar_url')
op.drop_column('users', 'telegram_last_name')
op.drop_column('users', 'telegram_first_name')

View File

@@ -1,10 +1,11 @@
from typing import Annotated from typing import Annotated
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
@@ -145,3 +146,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 from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram
router = APIRouter(prefix="/api/v1") router = APIRouter(prefix="/api/v1")
@@ -13,3 +13,5 @@ router.include_router(wheel.router)
router.include_router(feed.router) router.include_router(feed.router)
router.include_router(admin.router) router.include_router(admin.router)
router.include_router(events.router) router.include_router(events.router)
router.include_router(assignments.router)
router.include_router(telegram.router)

View File

@@ -0,0 +1,556 @@
"""
Assignment details and dispute system endpoints.
"""
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response, StreamingResponse
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import (
Assignment, AssignmentStatus, Participant, Challenge, User, Marathon,
Dispute, DisputeStatus, DisputeComment, DisputeVote,
)
from app.schemas import (
AssignmentDetailResponse, DisputeCreate, DisputeResponse,
DisputeCommentCreate, DisputeCommentResponse, DisputeVoteCreate,
MessageResponse, ChallengeResponse, GameShort, ReturnedAssignmentResponse,
)
from app.schemas.user import UserPublic
from app.services.storage import storage_service
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(tags=["assignments"])
# Dispute window: 24 hours after completion
DISPUTE_WINDOW_HOURS = 24
def user_to_public(user: User) -> UserPublic:
"""Convert User model to UserPublic schema"""
return UserPublic(
id=user.id,
login=user.login,
nickname=user.nickname,
avatar_url=None,
role=user.role,
created_at=user.created_at,
)
def build_dispute_response(dispute: Dispute, current_user_id: int) -> DisputeResponse:
"""Build DisputeResponse from Dispute model"""
votes_valid = sum(1 for v in dispute.votes if v.vote is True)
votes_invalid = sum(1 for v in dispute.votes if v.vote is False)
my_vote = None
for v in dispute.votes:
if v.user_id == current_user_id:
my_vote = v.vote
break
# Ensure expires_at has UTC timezone info for correct frontend parsing
created_at_utc = dispute.created_at.replace(tzinfo=timezone.utc) if dispute.created_at.tzinfo is None else dispute.created_at
expires_at = created_at_utc + timedelta(hours=DISPUTE_WINDOW_HOURS)
return DisputeResponse(
id=dispute.id,
raised_by=user_to_public(dispute.raised_by),
reason=dispute.reason,
status=dispute.status,
comments=[
DisputeCommentResponse(
id=c.id,
user=user_to_public(c.user),
text=c.text,
created_at=c.created_at,
)
for c in sorted(dispute.comments, key=lambda x: x.created_at)
],
votes=[
{
"user": user_to_public(v.user),
"vote": v.vote,
"created_at": v.created_at,
}
for v in dispute.votes
],
votes_valid=votes_valid,
votes_invalid=votes_invalid,
my_vote=my_vote,
expires_at=expires_at,
created_at=dispute.created_at,
resolved_at=dispute.resolved_at,
)
@router.get("/assignments/{assignment_id}", response_model=AssignmentDetailResponse)
async def get_assignment_detail(
assignment_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get detailed information about an assignment including proofs and dispute"""
# Get assignment with all relationships
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.participant).selectinload(Participant.user),
selectinload(Assignment.dispute).selectinload(Dispute.raised_by),
selectinload(Assignment.dispute).selectinload(Dispute.comments).selectinload(DisputeComment.user),
selectinload(Assignment.dispute).selectinload(Dispute.votes).selectinload(DisputeVote.user),
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Check user is participant of the marathon
marathon_id = assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Build response
challenge = assignment.challenge
game = challenge.game
owner_user = assignment.participant.user
# Determine if user can dispute
can_dispute = False
if (
assignment.status == AssignmentStatus.COMPLETED.value
and assignment.completed_at
and assignment.participant.user_id != current_user.id
and assignment.dispute is None
):
time_since_completion = datetime.utcnow() - assignment.completed_at
can_dispute = time_since_completion < timedelta(hours=DISPUTE_WINDOW_HOURS)
# Build proof URLs
proof_image_url = storage_service.get_url(assignment.proof_path, "proofs")
return AssignmentDetailResponse(
id=assignment.id,
challenge=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=storage_service.get_url(game.cover_path, "covers"),
),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
),
participant=user_to_public(owner_user),
status=assignment.status,
proof_url=assignment.proof_url,
proof_image_url=proof_image_url,
proof_comment=assignment.proof_comment,
points_earned=assignment.points_earned,
streak_at_completion=assignment.streak_at_completion,
started_at=assignment.started_at,
completed_at=assignment.completed_at,
can_dispute=can_dispute,
dispute=build_dispute_response(assignment.dispute, current_user.id) if assignment.dispute else None,
)
@router.get("/assignments/{assignment_id}/proof-media")
async def get_assignment_proof_media(
assignment_id: int,
request: Request,
current_user: CurrentUser,
db: DbSession,
):
"""Stream the proof media (image or video) for an assignment with Range support"""
# Get assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Check user is participant of the marathon
marathon_id = assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Check if proof exists
if not assignment.proof_path:
raise HTTPException(status_code=404, detail="No proof media for this assignment")
# Get file from storage
file_data = await storage_service.get_file(assignment.proof_path, "proofs")
if not file_data:
raise HTTPException(status_code=404, detail="Proof media not found in storage")
content, content_type = file_data
file_size = len(content)
# Check if it's a video and handle Range requests
is_video = content_type.startswith("video/")
if is_video:
range_header = request.headers.get("range")
if range_header:
# Parse range header
range_match = range_header.replace("bytes=", "").split("-")
start = int(range_match[0]) if range_match[0] else 0
end = int(range_match[1]) if range_match[1] else file_size - 1
# Ensure valid range
if start >= file_size:
raise HTTPException(status_code=416, detail="Range not satisfiable")
end = min(end, file_size - 1)
chunk = content[start:end + 1]
return Response(
content=chunk,
status_code=206,
media_type=content_type,
headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(len(chunk)),
"Cache-Control": "public, max-age=31536000",
}
)
# No range header - return full video with Accept-Ranges
return Response(
content=content,
media_type=content_type,
headers={
"Accept-Ranges": "bytes",
"Content-Length": str(file_size),
"Cache-Control": "public, max-age=31536000",
}
)
# For images, just return the content
return Response(
content=content,
media_type=content_type,
headers={
"Cache-Control": "public, max-age=31536000",
}
)
# Keep old endpoint for backwards compatibility
@router.get("/assignments/{assignment_id}/proof-image")
async def get_assignment_proof_image(
assignment_id: int,
request: Request,
current_user: CurrentUser,
db: DbSession,
):
"""Deprecated: Use proof-media instead. Redirects to proof-media."""
return await get_assignment_proof_media(assignment_id, request, current_user, db)
@router.post("/assignments/{assignment_id}/dispute", response_model=DisputeResponse)
async def create_dispute(
assignment_id: int,
data: DisputeCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Create a dispute against an assignment's proof"""
# Get assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.participant),
selectinload(Assignment.dispute),
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Check user is participant of the marathon
marathon_id = assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Validate
if assignment.status != AssignmentStatus.COMPLETED.value:
raise HTTPException(status_code=400, detail="Can only dispute completed assignments")
if assignment.participant.user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot dispute your own assignment")
if assignment.dispute:
raise HTTPException(status_code=400, detail="A dispute already exists for this assignment")
if not assignment.completed_at:
raise HTTPException(status_code=400, detail="Assignment has no completion date")
time_since_completion = datetime.utcnow() - assignment.completed_at
if time_since_completion >= timedelta(hours=DISPUTE_WINDOW_HOURS):
raise HTTPException(status_code=400, detail="Dispute window has expired (24 hours)")
# Create dispute
dispute = Dispute(
assignment_id=assignment_id,
raised_by_id=current_user.id,
reason=data.reason,
status=DisputeStatus.OPEN.value,
)
db.add(dispute)
await db.commit()
await db.refresh(dispute)
# Send notification to assignment owner
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if marathon:
await telegram_notifier.notify_dispute_raised(
db,
user_id=assignment.participant.user_id,
marathon_title=marathon.title,
challenge_title=assignment.challenge.title,
assignment_id=assignment_id
)
# Load relationships for response
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.raised_by),
selectinload(Dispute.comments).selectinload(DisputeComment.user),
selectinload(Dispute.votes).selectinload(DisputeVote.user),
)
.where(Dispute.id == dispute.id)
)
dispute = result.scalar_one()
return build_dispute_response(dispute, current_user.id)
@router.post("/disputes/{dispute_id}/comments", response_model=DisputeCommentResponse)
async def add_dispute_comment(
dispute_id: int,
data: DisputeCommentCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Add a comment to a dispute discussion"""
# Get dispute with assignment
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
)
.where(Dispute.id == dispute_id)
)
dispute = result.scalar_one_or_none()
if not dispute:
raise HTTPException(status_code=404, detail="Dispute not found")
if dispute.status != DisputeStatus.OPEN.value:
raise HTTPException(status_code=400, detail="Dispute is already resolved")
# Check user is participant of the marathon
marathon_id = dispute.assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Create comment
comment = DisputeComment(
dispute_id=dispute_id,
user_id=current_user.id,
text=data.text,
)
db.add(comment)
await db.commit()
await db.refresh(comment)
# Get user for response
result = await db.execute(select(User).where(User.id == current_user.id))
user = result.scalar_one()
return DisputeCommentResponse(
id=comment.id,
user=user_to_public(user),
text=comment.text,
created_at=comment.created_at,
)
@router.post("/disputes/{dispute_id}/vote", response_model=MessageResponse)
async def vote_on_dispute(
dispute_id: int,
data: DisputeVoteCreate,
current_user: CurrentUser,
db: DbSession,
):
"""Vote on a dispute (True = valid/proof is OK, False = invalid/proof is not OK)"""
# Get dispute with assignment
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
)
.where(Dispute.id == dispute_id)
)
dispute = result.scalar_one_or_none()
if not dispute:
raise HTTPException(status_code=404, detail="Dispute not found")
if dispute.status != DisputeStatus.OPEN.value:
raise HTTPException(status_code=400, detail="Dispute is already resolved")
# Check user is participant of the marathon
marathon_id = dispute.assignment.challenge.game.marathon_id
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Check if user already voted
result = await db.execute(
select(DisputeVote).where(
DisputeVote.dispute_id == dispute_id,
DisputeVote.user_id == current_user.id,
)
)
existing_vote = result.scalar_one_or_none()
if existing_vote:
# Update existing vote
existing_vote.vote = data.vote
existing_vote.created_at = datetime.utcnow()
else:
# Create new vote
vote = DisputeVote(
dispute_id=dispute_id,
user_id=current_user.id,
vote=data.vote,
)
db.add(vote)
await db.commit()
vote_text = "валидным" if data.vote else "невалидным"
return MessageResponse(message=f"Вы проголосовали: пруф {vote_text}")
@router.get("/marathons/{marathon_id}/returned-assignments", response_model=list[ReturnedAssignmentResponse])
async def get_returned_assignments(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get current user's returned assignments that need to be redone"""
# Check user is participant
result = await db.execute(
select(Participant).where(
Participant.user_id == current_user.id,
Participant.marathon_id == marathon_id,
)
)
participant = result.scalar_one_or_none()
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
# Get returned assignments
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.dispute),
)
.where(
Assignment.participant_id == participant.id,
Assignment.status == AssignmentStatus.RETURNED.value,
)
.order_by(Assignment.completed_at.asc()) # Oldest first
)
assignments = result.scalars().all()
return [
ReturnedAssignmentResponse(
id=a.id,
challenge=ChallengeResponse(
id=a.challenge.id,
title=a.challenge.title,
description=a.challenge.description,
type=a.challenge.type,
difficulty=a.challenge.difficulty,
points=a.challenge.points,
estimated_time=a.challenge.estimated_time,
proof_type=a.challenge.proof_type,
proof_hint=a.challenge.proof_hint,
game=GameShort(
id=a.challenge.game.id,
title=a.challenge.game.title,
cover_url=storage_service.get_url(a.challenge.game.cover_path, "covers"),
),
is_generated=a.challenge.is_generated,
created_at=a.challenge.created_at,
),
original_completed_at=a.completed_at,
dispute_reason=a.dispute.reason if a.dispute else "Оспорено",
)
for a in assignments
]

View File

@@ -1,16 +1,18 @@
from fastapi import APIRouter, HTTPException, status 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.core.rate_limit import limiter
from app.models import User from app.models import User
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPublic from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate
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 +36,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=TokenResponse)
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()
@@ -55,10 +58,11 @@ async def login(data: UserLogin, 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.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

@@ -13,13 +13,12 @@ from app.schemas import (
ChallengePreview, ChallengePreview,
ChallengesPreviewResponse, ChallengesPreviewResponse,
ChallengesSaveRequest, ChallengesSaveRequest,
ChallengesGenerateRequest,
) )
from app.services.gpt import GPTService from app.services.gpt import gpt_service
router = APIRouter(tags=["challenges"]) router = APIRouter(tags=["challenges"])
gpt_service = GPTService()
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(
@@ -189,7 +188,12 @@ async def create_challenge(
@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse) @router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse)
async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession): async def preview_challenges(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
data: ChallengesGenerateRequest | None = None,
):
"""Generate challenges preview for approved games in marathon using GPT (without saving). Organizers only.""" """Generate challenges preview for approved games in marathon using GPT (without saving). Organizers only."""
# Check marathon # Check marathon
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id)) result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
@@ -204,33 +208,60 @@ 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")
preview_challenges = [] # Build games list for generation (skip games that already have challenges, unless specific IDs requested)
games_to_generate = []
game_map = {}
for game in games: for game in games:
# Check if game already has challenges # 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)
) )
if existing: if not existing:
continue # Skip if already has challenges games_to_generate.append({
"id": game.id,
"title": game.title,
"genre": game.genre
})
game_map[game.id] = game.title
if not games_to_generate:
return ChallengesPreviewResponse(challenges=[])
# Generate challenges for all games in one API call
preview_challenges = []
try: try:
challenges_data = await gpt_service.generate_challenges(game.title, game.genre) challenges_by_game = await gpt_service.generate_challenges(games_to_generate)
for game_id, challenges_data in challenges_by_game.items():
game_title = game_map.get(game_id, "Unknown")
for ch_data in challenges_data: for ch_data in challenges_data:
preview_challenges.append(ChallengePreview( preview_challenges.append(ChallengePreview(
game_id=game.id, game_id=game_id,
game_title=game.title, game_title=game_title,
title=ch_data.title, title=ch_data.title,
description=ch_data.description, description=ch_data.description,
type=ch_data.type, type=ch_data.type,
@@ -242,8 +273,7 @@ async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: Db
)) ))
except Exception as e: except Exception as e:
# Log error but continue with other games print(f"Error generating challenges: {e}")
print(f"Error generating challenges for {game.title}: {e}")
return ChallengesPreviewResponse(challenges=preview_challenges) return ChallengesPreviewResponse(challenges=preview_challenges)

View File

@@ -10,15 +10,19 @@ from app.models import (
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge, Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge,
SwapRequest as SwapRequestModel, SwapRequestStatus, User, SwapRequest as SwapRequestModel, SwapRequestStatus, User,
) )
from fastapi import UploadFile, File, Form
from app.schemas import ( from app.schemas import (
EventCreate, EventResponse, ActiveEventResponse, EventEffects, EventCreate, EventResponse, ActiveEventResponse, EventEffects,
MessageResponse, SwapRequest, ChallengeResponse, GameShort, SwapCandidate, MessageResponse, SwapRequest, ChallengeResponse, GameShort, SwapCandidate,
SwapRequestCreate, SwapRequestResponse, SwapRequestChallengeInfo, MySwapRequests, SwapRequestCreate, SwapRequestResponse, SwapRequestChallengeInfo, MySwapRequests,
CommonEnemyLeaderboard, CommonEnemyLeaderboard, EventAssignmentResponse, AssignmentResponse, CompleteResult,
) )
from app.core.config import settings
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
from app.schemas.user import UserPublic from app.schemas.user import UserPublic
from app.services.events import event_service from app.services.events import event_service
from app.services.storage import storage_service
router = APIRouter(tags=["events"]) router = APIRouter(tags=["events"])
@@ -635,129 +639,173 @@ async def cancel_swap_request(
return MessageResponse(message="Swap request cancelled") return MessageResponse(message="Swap request cancelled")
@router.post("/marathons/{marathon_id}/rematch/{assignment_id}", response_model=MessageResponse) # ==================== Game Choice Event Endpoints ====================
async def rematch_assignment(
class GameChoiceChallengeResponse(BaseModel):
"""Challenge option for game choice event"""
id: int
title: str
description: str
difficulty: str
points: int
estimated_time: int | None
proof_type: str
proof_hint: str | None
class GameChoiceChallengesResponse(BaseModel):
"""Response with available challenges for game choice"""
game_id: int
game_title: str
challenges: list[GameChoiceChallengeResponse]
class GameChoiceSelectRequest(BaseModel):
"""Request to select a challenge during game choice event"""
challenge_id: int
@router.get("/marathons/{marathon_id}/game-choice/challenges", response_model=GameChoiceChallengesResponse)
async def get_game_choice_challenges(
marathon_id: int, marathon_id: int,
assignment_id: int, game_id: int,
current_user: CurrentUser, current_user: CurrentUser,
db: DbSession, db: DbSession,
): ):
"""Retry a dropped assignment (during rematch event)""" """Get 3 random challenges from a game for game choice event"""
from app.models import Game
from sqlalchemy.sql.expression import func
await get_marathon_or_404(db, marathon_id) await get_marathon_or_404(db, marathon_id)
participant = await require_participant(db, current_user.id, marathon_id) participant = await require_participant(db, current_user.id, marathon_id)
# Check active rematch event # Check active game_choice event
event = await event_service.get_active_event(db, marathon_id) event = await event_service.get_active_event(db, marathon_id)
if not event or event.type != EventType.REMATCH.value: if not event or event.type != EventType.GAME_CHOICE.value:
raise HTTPException(status_code=400, detail="No active rematch event") raise HTTPException(status_code=400, detail="No active game choice event")
# Check no current active assignment # Get the game
result = await db.execute( result = await db.execute(
select(Assignment).where( select(Game).where(Game.id == game_id, Game.marathon_id == marathon_id)
Assignment.participant_id == participant.id,
Assignment.status == AssignmentStatus.ACTIVE.value,
) )
) game = result.scalar_one_or_none()
if result.scalar_one_or_none(): if not game:
raise HTTPException(status_code=400, detail="You already have an active assignment") raise HTTPException(status_code=404, detail="Game not found")
# Get the dropped assignment # Get 3 random challenges from this game
result = await db.execute(
select(Challenge)
.where(Challenge.game_id == game_id)
.order_by(func.random())
.limit(3)
)
challenges = result.scalars().all()
if not challenges:
raise HTTPException(status_code=400, detail="No challenges available for this game")
return GameChoiceChallengesResponse(
game_id=game.id,
game_title=game.title,
challenges=[
GameChoiceChallengeResponse(
id=c.id,
title=c.title,
description=c.description,
difficulty=c.difficulty,
points=c.points,
estimated_time=c.estimated_time,
proof_type=c.proof_type,
proof_hint=c.proof_hint,
)
for c in challenges
],
)
@router.post("/marathons/{marathon_id}/game-choice/select", response_model=MessageResponse)
async def select_game_choice_challenge(
marathon_id: int,
data: GameChoiceSelectRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Select a challenge during game choice event (replaces current assignment if any)"""
await get_marathon_or_404(db, marathon_id)
participant = await require_participant(db, current_user.id, marathon_id)
# Check active game_choice event
event = await event_service.get_active_event(db, marathon_id)
if not event or event.type != EventType.GAME_CHOICE.value:
raise HTTPException(status_code=400, detail="No active game choice event")
# Get the challenge
result = await db.execute(
select(Challenge)
.options(selectinload(Challenge.game))
.where(Challenge.id == data.challenge_id)
)
challenge = result.scalar_one_or_none()
if not challenge:
raise HTTPException(status_code=404, detail="Challenge not found")
# Verify challenge belongs to this marathon
if challenge.game.marathon_id != marathon_id:
raise HTTPException(status_code=400, detail="Challenge does not belong to this marathon")
# Check for current active assignment (non-event)
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options(selectinload(Assignment.challenge)) .options(selectinload(Assignment.challenge))
.where( .where(
Assignment.id == assignment_id,
Assignment.participant_id == participant.id, Assignment.participant_id == participant.id,
Assignment.status == AssignmentStatus.DROPPED.value, Assignment.status == AssignmentStatus.ACTIVE.value,
Assignment.is_event_assignment == False,
) )
) )
dropped = result.scalar_one_or_none() current_assignment = result.scalar_one_or_none()
if not dropped:
raise HTTPException(status_code=404, detail="Dropped assignment not found")
# Create new assignment for the same challenge (with rematch event_type for 50% points) # If there's a current assignment, replace it (free drop during this event)
old_challenge_title = None
if current_assignment:
old_challenge_title = current_assignment.challenge.title
# Mark old assignment as dropped (no penalty during game_choice event)
current_assignment.status = AssignmentStatus.DROPPED.value
current_assignment.completed_at = datetime.utcnow()
# Create new assignment with chosen challenge
new_assignment = Assignment( new_assignment = Assignment(
participant_id=participant.id, participant_id=participant.id,
challenge_id=dropped.challenge_id, challenge_id=data.challenge_id,
status=AssignmentStatus.ACTIVE.value, status=AssignmentStatus.ACTIVE.value,
event_type=EventType.REMATCH.value, event_type=EventType.GAME_CHOICE.value,
) )
db.add(new_assignment) db.add(new_assignment)
# Log activity # Log activity
activity_data = {
"game": challenge.game.title,
"challenge": challenge.title,
"event_type": EventType.GAME_CHOICE.value,
}
if old_challenge_title:
activity_data["replaced_challenge"] = old_challenge_title
activity = Activity( activity = Activity(
marathon_id=marathon_id, marathon_id=marathon_id,
user_id=current_user.id, user_id=current_user.id,
type=ActivityType.REMATCH.value, type=ActivityType.SPIN.value, # Treat as a spin activity
data={ data=activity_data,
"challenge": dropped.challenge.title,
"original_assignment_id": assignment_id,
},
) )
db.add(activity) db.add(activity)
await db.commit() await db.commit()
return MessageResponse(message="Rematch started! Complete for 50% points") if old_challenge_title:
return MessageResponse(message=f"Задание заменено! Теперь у вас: {challenge.title}")
else:
class DroppedAssignmentResponse(BaseModel): return MessageResponse(message=f"Задание выбрано: {challenge.title}")
id: int
challenge: ChallengeResponse
dropped_at: datetime
class Config:
from_attributes = True
@router.get("/marathons/{marathon_id}/dropped-assignments", response_model=list[DroppedAssignmentResponse])
async def get_dropped_assignments(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get dropped assignments that can be rematched"""
await get_marathon_or_404(db, marathon_id)
participant = await require_participant(db, current_user.id, marathon_id)
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(
Assignment.participant_id == participant.id,
Assignment.status == AssignmentStatus.DROPPED.value,
)
.order_by(Assignment.started_at.desc())
)
dropped = result.scalars().all()
return [
DroppedAssignmentResponse(
id=a.id,
challenge=ChallengeResponse(
id=a.challenge.id,
title=a.challenge.title,
description=a.challenge.description,
type=a.challenge.type,
difficulty=a.challenge.difficulty,
points=a.challenge.points,
estimated_time=a.challenge.estimated_time,
proof_type=a.challenge.proof_type,
proof_hint=a.challenge.proof_hint,
game=GameShort(
id=a.challenge.game.id,
title=a.challenge.game.title,
cover_url=None,
),
is_generated=a.challenge.is_generated,
created_at=a.challenge.created_at,
),
dropped_at=a.completed_at or a.started_at,
)
for a in dropped
]
@router.get("/marathons/{marathon_id}/swap-candidates", response_model=list[SwapCandidate]) @router.get("/marathons/{marathon_id}/swap-candidates", response_model=list[SwapCandidate])
@@ -864,3 +912,262 @@ async def get_common_enemy_leaderboard(
) )
return leaderboard return leaderboard
# ==================== Event Assignment Endpoints ====================
def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
"""Convert Assignment model to AssignmentResponse"""
challenge = assignment.challenge
game = challenge.game
return AssignmentResponse(
id=assignment.id,
challenge=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=storage_service.get_url(game.cover_path, "covers"),
),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
),
status=assignment.status,
proof_url=storage_service.get_url(assignment.proof_path, "proofs") if assignment.proof_path else assignment.proof_url,
proof_comment=assignment.proof_comment,
points_earned=assignment.points_earned,
streak_at_completion=assignment.streak_at_completion,
started_at=assignment.started_at,
completed_at=assignment.completed_at,
)
@router.get("/marathons/{marathon_id}/event-assignment", response_model=EventAssignmentResponse)
async def get_event_assignment(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Get current user's event assignment (Common Enemy)"""
await get_marathon_or_404(db, marathon_id)
participant = await require_participant(db, current_user.id, marathon_id)
# Get active common enemy event
event = await event_service.get_active_event(db, marathon_id)
# Find event assignment for this participant
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(
Assignment.participant_id == participant.id,
Assignment.is_event_assignment == True,
)
.order_by(Assignment.started_at.desc())
)
assignment = result.scalar_one_or_none()
# Check if completed
is_completed = assignment.status == AssignmentStatus.COMPLETED.value if assignment else False
# If no active event but we have an assignment, it might be from a past event
# Only return it if the event is still active
if not event or event.type != EventType.COMMON_ENEMY.value:
# Check if assignment belongs to an inactive event
if assignment and assignment.event_id:
result = await db.execute(
select(Event).where(Event.id == assignment.event_id)
)
assignment_event = result.scalar_one_or_none()
if assignment_event and not assignment_event.is_active:
# Event ended, don't return the assignment
return EventAssignmentResponse(
assignment=None,
event_id=None,
challenge_id=None,
is_completed=False,
)
return EventAssignmentResponse(
assignment=assignment_to_response(assignment) if assignment else None,
event_id=event.id if event else None,
challenge_id=event.data.get("challenge_id") if event and event.data else None,
is_completed=is_completed,
)
@router.post("/event-assignments/{assignment_id}/complete", response_model=CompleteResult)
async def complete_event_assignment(
assignment_id: int,
current_user: CurrentUser,
db: DbSession,
proof_url: str | None = Form(None),
comment: str | None = Form(None),
proof_file: UploadFile | None = File(None),
):
"""Complete an event assignment (Common Enemy) with proof"""
from app.services.points import PointsService
points_service = PointsService()
# Get assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.participant),
selectinload(Assignment.challenge).selectinload(Challenge.game),
)
.where(Assignment.id == assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
if assignment.participant.user_id != current_user.id:
raise HTTPException(status_code=403, detail="This is not your assignment")
if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Assignment is not active")
# Must be event assignment
if not assignment.is_event_assignment:
raise HTTPException(status_code=400, detail="This is not an event assignment")
# Need either file or URL
if not proof_file and not proof_url:
raise HTTPException(status_code=400, detail="Proof is required (file or URL)")
# Handle file upload
if proof_file:
contents = await proof_file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=400,
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
)
ext = proof_file.filename.split(".")[-1].lower() if proof_file.filename else "jpg"
if ext not in settings.ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}",
)
# Upload file to storage
filename = storage_service.generate_filename(assignment_id, proof_file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="proofs",
filename=filename,
content_type=proof_file.content_type or "application/octet-stream",
)
assignment.proof_path = file_path
else:
assignment.proof_url = proof_url
assignment.proof_comment = comment
# Get marathon_id
marathon_id = assignment.challenge.game.marathon_id
# Get active event for bonus calculation
active_event = await event_service.get_active_event(db, marathon_id)
# Calculate base points (no streak bonus for event assignments)
participant = assignment.participant
challenge = assignment.challenge
base_points = challenge.points
# Handle common enemy bonus
common_enemy_bonus = 0
common_enemy_closed = False
common_enemy_winners = None
if active_event and active_event.type == EventType.COMMON_ENEMY.value:
common_enemy_bonus, common_enemy_closed, common_enemy_winners = await event_service.record_common_enemy_completion(
db, active_event, participant.id, current_user.id
)
total_points = base_points + common_enemy_bonus
# Update assignment
assignment.status = AssignmentStatus.COMPLETED.value
assignment.points_earned = total_points
assignment.completed_at = datetime.utcnow()
# Update participant points (event assignments add to total but don't affect streak)
participant.total_points += total_points
# Log activity
activity_data = {
"assignment_id": assignment.id,
"game": challenge.game.title,
"challenge": challenge.title,
"difficulty": challenge.difficulty,
"points": total_points,
"event_type": EventType.COMMON_ENEMY.value,
"is_event_assignment": True,
}
if common_enemy_bonus:
activity_data["common_enemy_bonus"] = common_enemy_bonus
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.COMPLETE.value,
data=activity_data,
)
db.add(activity)
# If common enemy event auto-closed, log the event end with winners
if common_enemy_closed and common_enemy_winners:
# Load winner nicknames
winner_user_ids = [w["user_id"] for w in common_enemy_winners]
users_result = await db.execute(
select(User).where(User.id.in_(winner_user_ids))
)
users_map = {u.id: u.nickname for u in users_result.scalars().all()}
winners_data = [
{
"user_id": w["user_id"],
"nickname": users_map.get(w["user_id"], "Unknown"),
"rank": w["rank"],
"bonus_points": COMMON_ENEMY_BONUSES.get(w["rank"], 0),
}
for w in common_enemy_winners
]
event_end_activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.EVENT_END.value,
data={
"event_type": EventType.COMMON_ENEMY.value,
"event_name": EVENT_INFO.get(EventType.COMMON_ENEMY, {}).get("name", "Общий враг"),
"auto_closed": True,
"winners": winners_data,
},
)
db.add(event_end_activity)
await db.commit()
return CompleteResult(
points_earned=total_points,
streak_bonus=0, # Event assignments don't give streak bonus
total_points=participant.total_points,
new_streak=participant.current_streak, # Streak unchanged
)

View File

@@ -3,7 +3,8 @@ 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
from app.models import Activity, Participant from app.models import Activity, Participant, Dispute, ActivityType
from app.models.dispute import DisputeStatus
from app.schemas import FeedResponse, ActivityResponse, UserPublic from app.schemas import FeedResponse, ActivityResponse, UserPublic
router = APIRouter(tags=["feed"]) router = APIRouter(tags=["feed"])
@@ -44,16 +45,40 @@ async def get_feed(
) )
activities = result.scalars().all() activities = result.scalars().all()
items = [ # Get assignment_ids from complete activities to check for disputes
complete_assignment_ids = []
for a in activities:
if a.type == ActivityType.COMPLETE.value and a.data and a.data.get("assignment_id"):
complete_assignment_ids.append(a.data["assignment_id"])
# Get disputes for these assignments
disputes_map: dict[int, str] = {}
if complete_assignment_ids:
result = await db.execute(
select(Dispute).where(Dispute.assignment_id.in_(complete_assignment_ids))
)
for dispute in result.scalars().all():
disputes_map[dispute.assignment_id] = dispute.status
items = []
for a in activities:
data = dict(a.data) if a.data else {}
# Add dispute status to complete activities
if a.type == ActivityType.COMPLETE.value and a.data and a.data.get("assignment_id"):
assignment_id = a.data["assignment_id"]
if assignment_id in disputes_map:
data["dispute_status"] = disputes_map[assignment_id]
items.append(
ActivityResponse( ActivityResponse(
id=a.id, id=a.id,
type=a.type, type=a.type,
user=UserPublic.model_validate(a.user), user=UserPublic.model_validate(a.user),
data=a.data, data=data if data else None,
created_at=a.created_at, created_at=a.created_at,
) )
for a in activities )
]
return FeedResponse( return FeedResponse(
items=items, items=items,

View File

@@ -1,8 +1,6 @@
from fastapi import APIRouter, HTTPException, status, UploadFile, File, Query from fastapi import APIRouter, HTTPException, status, UploadFile, File, Query
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
import uuid
from pathlib import Path
from app.api.deps import ( from app.api.deps import (
DbSession, CurrentUser, DbSession, CurrentUser,
@@ -11,6 +9,8 @@ from app.api.deps import (
from app.core.config import settings 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.telegram_notifier import telegram_notifier
router = APIRouter(tags=["games"]) router = APIRouter(tags=["games"])
@@ -35,7 +35,7 @@ def game_to_response(game: Game, challenges_count: int = 0) -> GameResponse:
return GameResponse( return GameResponse(
id=game.id, id=game.id,
title=game.title, title=game.title,
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None, cover_url=storage_service.get_url(game.cover_path, "covers"),
download_url=game.download_url, download_url=game.download_url,
genre=game.genre, genre=game.genre,
status=game.status, status=game.status,
@@ -269,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
@@ -284,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(
@@ -303,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
@@ -317,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(
@@ -354,15 +381,20 @@ async def upload_cover(
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}", detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
) )
# Save file # Delete old cover if exists
filename = f"{game_id}_{uuid.uuid4().hex}.{ext}" if game.cover_path:
filepath = Path(settings.UPLOAD_DIR) / "covers" / filename await storage_service.delete_file(game.cover_path)
filepath.parent.mkdir(parents=True, exist_ok=True)
with open(filepath, "wb") as f: # Upload file
f.write(contents) filename = storage_service.generate_filename(game_id, file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="covers",
filename=filename,
content_type=file.content_type or "image/jpeg",
)
game.cover_path = str(filepath) game.cover_path = file_path
await db.commit() await db.commit()
return await get_game(game_id, current_user, db) return await get_game(game_id, current_user, db)

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 (
@@ -27,6 +33,7 @@ from app.schemas import (
UserPublic, UserPublic,
SetParticipantRole, SetParticipantRole,
) )
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(prefix="/marathons", tags=["marathons"]) router = APIRouter(prefix="/marathons", tags=["marathons"])
@@ -39,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()
@@ -61,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:
@@ -184,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)
@@ -271,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
@@ -294,6 +330,9 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
await db.commit() await db.commit()
# Send Telegram notifications
await telegram_notifier.notify_marathon_start(db, marathon_id, marathon.title)
return await get_marathon(marathon_id, current_user, db) return await get_marathon(marathon_id, current_user, db)
@@ -319,13 +358,16 @@ async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSes
await db.commit() await db.commit()
# Send Telegram notifications
await telegram_notifier.notify_marathon_finish(db, marathon_id, marathon.title)
return await get_marathon(marathon_id, current_user, db) return await get_marathon(marathon_id, current_user, db)
@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()
@@ -400,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)
@@ -469,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

@@ -0,0 +1,393 @@
import logging
from fastapi import APIRouter
from pydantic import BaseModel
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser, BotSecretDep
from app.core.config import settings
from app.core.security import create_telegram_link_token, verify_telegram_link_token
from app.models import User, Participant, Marathon, Assignment, Challenge, Event, Game
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/telegram", tags=["telegram"])
# Schemas
class TelegramLinkToken(BaseModel):
token: str
bot_url: str
class TelegramConfirmLink(BaseModel):
token: str
telegram_id: int
telegram_username: str | None = None
telegram_first_name: str | None = None
telegram_last_name: str | None = None
telegram_avatar_url: str | None = None
class TelegramLinkResponse(BaseModel):
success: bool
nickname: str | None = None
error: str | None = None
class TelegramUserResponse(BaseModel):
id: int
nickname: str
login: str
avatar_url: str | None = None
class Config:
from_attributes = True
class TelegramMarathonResponse(BaseModel):
id: int
title: str
status: str
total_points: int
position: int
class Config:
from_attributes = True
class TelegramMarathonDetails(BaseModel):
marathon: dict
participant: dict
position: int
active_events: list[dict]
current_assignment: dict | None
class TelegramStatsResponse(BaseModel):
marathons_completed: int
marathons_active: int
challenges_completed: int
total_points: int
best_streak: int
# Endpoints
@router.post("/generate-link-token", response_model=TelegramLinkToken)
async def generate_link_token(current_user: CurrentUser):
"""Generate a one-time token for Telegram account linking."""
logger.info(f"[TG_LINK] Generating link token for user {current_user.id} ({current_user.nickname})")
# Create a short token (≤64 chars) for Telegram deep link
token = create_telegram_link_token(
user_id=current_user.id,
expire_minutes=settings.TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES
)
logger.info(f"[TG_LINK] Token created: {token} (length: {len(token)})")
bot_username = settings.TELEGRAM_BOT_USERNAME or "GameMarathonBot"
bot_url = f"https://t.me/{bot_username}?start={token}"
logger.info(f"[TG_LINK] Bot URL: {bot_url}")
return TelegramLinkToken(token=token, bot_url=bot_url)
@router.post("/confirm-link", response_model=TelegramLinkResponse)
async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession, _: BotSecretDep):
"""Confirm Telegram account linking (called by bot)."""
logger.info(f"[TG_CONFIRM] ========== CONFIRM LINK REQUEST ==========")
logger.info(f"[TG_CONFIRM] telegram_id: {data.telegram_id}")
logger.info(f"[TG_CONFIRM] telegram_username: {data.telegram_username}")
logger.info(f"[TG_CONFIRM] token: {data.token}")
# Verify short token and extract user_id
user_id = verify_telegram_link_token(data.token)
logger.info(f"[TG_CONFIRM] Verified user_id: {user_id}")
if user_id is None:
logger.error(f"[TG_CONFIRM] FAILED: Token invalid or expired")
return TelegramLinkResponse(success=False, error="Ссылка недействительна или устарела")
# Get user
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
logger.info(f"[TG_CONFIRM] Found user: {user.nickname if user else 'NOT FOUND'}")
if not user:
logger.error(f"[TG_CONFIRM] FAILED: User not found")
return TelegramLinkResponse(success=False, error="Пользователь не найден")
# Check if telegram_id already linked to another user
result = await db.execute(
select(User).where(User.telegram_id == data.telegram_id, User.id != user_id)
)
existing_user = result.scalar_one_or_none()
if existing_user:
logger.error(f"[TG_CONFIRM] FAILED: Telegram already linked to user {existing_user.id}")
return TelegramLinkResponse(
success=False,
error="Этот Telegram аккаунт уже привязан к другому пользователю"
)
# Link account
logger.info(f"[TG_CONFIRM] Linking telegram_id={data.telegram_id} to user_id={user_id}")
user.telegram_id = data.telegram_id
user.telegram_username = data.telegram_username
user.telegram_first_name = data.telegram_first_name
user.telegram_last_name = data.telegram_last_name
user.telegram_avatar_url = data.telegram_avatar_url
await db.commit()
logger.info(f"[TG_CONFIRM] SUCCESS! User {user.nickname} linked to Telegram {data.telegram_id}")
return TelegramLinkResponse(success=True, nickname=user.nickname)
@router.get("/user/{telegram_id}", response_model=TelegramUserResponse | None)
async def get_user_by_telegram_id(telegram_id: int, db: DbSession, _: BotSecretDep):
"""Get user by Telegram ID."""
logger.info(f"[TG_USER] Looking up user by telegram_id={telegram_id}")
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
logger.info(f"[TG_USER] No user found for telegram_id={telegram_id}")
return None
logger.info(f"[TG_USER] Found user: {user.id} ({user.nickname})")
return TelegramUserResponse(
id=user.id,
nickname=user.nickname,
login=user.login,
avatar_url=user.avatar_url
)
@router.post("/unlink/{telegram_id}", response_model=TelegramLinkResponse)
async def unlink_telegram(telegram_id: int, db: DbSession, _: BotSecretDep):
"""Unlink Telegram account."""
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
return TelegramLinkResponse(success=False, error="Аккаунт не найден")
user.telegram_id = None
user.telegram_username = None
await db.commit()
return TelegramLinkResponse(success=True)
@router.get("/marathons/{telegram_id}", response_model=list[TelegramMarathonResponse])
async def get_user_marathons(telegram_id: int, db: DbSession, _: BotSecretDep):
"""Get user's marathons by Telegram ID."""
# Get user
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
return []
# Get participations with marathons
result = await db.execute(
select(Participant, Marathon)
.join(Marathon, Participant.marathon_id == Marathon.id)
.where(Participant.user_id == user.id)
.order_by(Marathon.created_at.desc())
)
participations = result.all()
marathons = []
for participant, marathon in participations:
# Calculate position
position_result = await db.execute(
select(func.count(Participant.id) + 1)
.where(
Participant.marathon_id == marathon.id,
Participant.total_points > participant.total_points
)
)
position = position_result.scalar() or 1
marathons.append(TelegramMarathonResponse(
id=marathon.id,
title=marathon.title,
status=marathon.status.value if hasattr(marathon.status, 'value') else marathon.status,
total_points=participant.total_points,
position=position
))
return marathons
@router.get("/marathon/{marathon_id}", response_model=TelegramMarathonDetails | None)
async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession, _: BotSecretDep):
"""Get marathon details for user by Telegram ID."""
# Get user
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
return None
# Get marathon
result = await db.execute(
select(Marathon).where(Marathon.id == marathon_id)
)
marathon = result.scalar_one_or_none()
if not marathon:
return None
# Get participant
result = await db.execute(
select(Participant)
.where(Participant.marathon_id == marathon_id, Participant.user_id == user.id)
)
participant = result.scalar_one_or_none()
if not participant:
return None
# Calculate position
position_result = await db.execute(
select(func.count(Participant.id) + 1)
.where(
Participant.marathon_id == marathon_id,
Participant.total_points > participant.total_points
)
)
position = position_result.scalar() or 1
# Get active events
result = await db.execute(
select(Event)
.where(Event.marathon_id == marathon_id, Event.is_active == True)
)
active_events = result.scalars().all()
events_data = [
{
"id": e.id,
"type": e.type.value if hasattr(e.type, 'value') else e.type,
"start_time": e.start_time.isoformat() if e.start_time else None,
"end_time": e.end_time.isoformat() if e.end_time else None
}
for e in active_events
]
# Get current assignment
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(
Assignment.participant_id == participant.id,
Assignment.status == "active"
)
.order_by(Assignment.started_at.desc())
.limit(1)
)
assignment = result.scalar_one_or_none()
assignment_data = None
if assignment:
challenge = assignment.challenge
game = challenge.game if challenge else None
assignment_data = {
"id": assignment.id,
"status": assignment.status.value if hasattr(assignment.status, 'value') else assignment.status,
"challenge": {
"id": challenge.id if challenge else None,
"title": challenge.title if challenge else None,
"difficulty": challenge.difficulty.value if challenge and hasattr(challenge.difficulty, 'value') else (challenge.difficulty if challenge else None),
"points": challenge.points if challenge else None,
"game": {
"id": game.id if game else None,
"title": game.title if game else None
}
} if challenge else None
}
return TelegramMarathonDetails(
marathon={
"id": marathon.id,
"title": marathon.title,
"status": marathon.status.value if hasattr(marathon.status, 'value') else marathon.status,
"description": marathon.description
},
participant={
"total_points": participant.total_points,
"current_streak": participant.current_streak,
"drop_count": participant.drop_count
},
position=position,
active_events=events_data,
current_assignment=assignment_data
)
@router.get("/stats/{telegram_id}", response_model=TelegramStatsResponse | None)
async def get_user_stats(telegram_id: int, db: DbSession, _: BotSecretDep):
"""Get user's overall statistics by Telegram ID."""
# Get user
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
return None
# Get participations
result = await db.execute(
select(Participant, Marathon)
.join(Marathon, Participant.marathon_id == Marathon.id)
.where(Participant.user_id == user.id)
)
participations = result.all()
marathons_completed = 0
marathons_active = 0
total_points = 0
best_streak = 0
for participant, marathon in participations:
status = marathon.status.value if hasattr(marathon.status, 'value') else marathon.status
if status == "finished":
marathons_completed += 1
elif status == "active":
marathons_active += 1
total_points += participant.total_points
if participant.current_streak > best_streak:
best_streak = participant.current_streak
# Count completed assignments
result = await db.execute(
select(func.count(Assignment.id))
.join(Participant, Assignment.participant_id == Participant.id)
.where(Participant.user_id == user.id, Assignment.status == "completed")
)
challenges_completed = result.scalar() or 0
return TelegramStatsResponse(
marathons_completed=marathons_completed,
marathons_active=marathons_active,
challenges_completed=challenges_completed,
total_points=total_points,
best_streak=best_streak
)

View File

@@ -1,18 +1,24 @@
from fastapi import APIRouter, HTTPException, status, UploadFile, File from fastapi import APIRouter, HTTPException, status, UploadFile, File, Response
from sqlalchemy import select from sqlalchemy import select, func
import uuid
from pathlib import Path
from app.api.deps import DbSession, CurrentUser from app.api.deps import DbSession, CurrentUser
from app.core.config import settings from app.core.config import settings
from app.models import User from app.core.security import verify_password, get_password_hash
from app.schemas import UserPublic, UserUpdate, TelegramLink, MessageResponse from app.models import User, Participant, Assignment, Marathon
from app.models.assignment import AssignmentStatus
from app.models.marathon import MarathonStatus
from app.schemas import (
UserPublic, UserPrivate, UserUpdate, TelegramLink, MessageResponse,
PasswordChange, UserStats, UserProfilePublic,
)
from app.services.storage import storage_service
router = APIRouter(prefix="/users", tags=["users"]) 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()
@@ -25,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(
@@ -64,20 +100,25 @@ async def upload_avatar(
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}", detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
) )
# Save file # Delete old avatar if exists
filename = f"{current_user.id}_{uuid.uuid4().hex}.{ext}" if current_user.avatar_path:
filepath = Path(settings.UPLOAD_DIR) / "avatars" / filename await storage_service.delete_file(current_user.avatar_path)
filepath.parent.mkdir(parents=True, exist_ok=True)
with open(filepath, "wb") as f: # Upload file
f.write(contents) filename = storage_service.generate_filename(current_user.id, file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="avatars",
filename=filename,
content_type=file.content_type or "image/jpeg",
)
# Update user # Update user
current_user.avatar_path = str(filepath) current_user.avatar_path = file_path
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)
@@ -102,3 +143,161 @@ async def link_telegram(
await db.commit() await db.commit()
return MessageResponse(message="Telegram account linked successfully") return MessageResponse(message="Telegram account linked successfully")
@router.post("/me/telegram/unlink", response_model=MessageResponse)
async def unlink_telegram(
current_user: CurrentUser,
db: DbSession,
):
if not current_user.telegram_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Telegram account is not linked",
)
current_user.telegram_id = None
current_user.telegram_username = None
await db.commit()
return MessageResponse(message="Telegram account unlinked successfully")
@router.post("/me/password", response_model=MessageResponse)
async def change_password(
data: PasswordChange,
current_user: CurrentUser,
db: DbSession,
):
"""Смена пароля текущего пользователя"""
if not verify_password(data.current_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Неверный текущий пароль",
)
if data.current_password == data.new_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Новый пароль должен отличаться от текущего",
)
current_user.password_hash = get_password_hash(data.new_password)
await db.commit()
return MessageResponse(message="Пароль успешно изменен")
@router.get("/me/stats", response_model=UserStats)
async def get_my_stats(current_user: CurrentUser, db: DbSession):
"""Получить свою статистику"""
return await _get_user_stats(current_user.id, db)
@router.get("/{user_id}/stats", response_model=UserStats)
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))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return await _get_user_stats(user_id, db)
@router.get("/{user_id}/profile", response_model=UserProfilePublic)
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))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
stats = await _get_user_stats(user_id, db)
return UserProfilePublic(
id=user.id,
nickname=user.nickname,
avatar_url=user.avatar_url,
created_at=user.created_at,
stats=stats,
)
async def _get_user_stats(user_id: int, db) -> UserStats:
"""Вспомогательная функция для подсчета статистики пользователя"""
# 1. Количество марафонов (участий)
marathons_result = await db.execute(
select(func.count(Participant.id))
.where(Participant.user_id == user_id)
)
marathons_count = marathons_result.scalar() or 0
# 2. Количество побед (1 место в завершенных марафонах)
wins_count = 0
user_participations = await db.execute(
select(Participant)
.join(Marathon, Marathon.id == Participant.marathon_id)
.where(
Participant.user_id == user_id,
Marathon.status == MarathonStatus.FINISHED.value
)
)
for participation in user_participations.scalars():
# Для каждого марафона проверяем, был ли пользователь первым
max_points_result = await db.execute(
select(func.max(Participant.total_points))
.where(Participant.marathon_id == participation.marathon_id)
)
max_points = max_points_result.scalar() or 0
if participation.total_points == max_points and max_points > 0:
# Проверяем что он единственный с такими очками (не ничья)
count_with_max = await db.execute(
select(func.count(Participant.id))
.where(
Participant.marathon_id == participation.marathon_id,
Participant.total_points == max_points
)
)
if count_with_max.scalar() == 1:
wins_count += 1
# 3. Выполненных заданий
completed_result = await db.execute(
select(func.count(Assignment.id))
.join(Participant, Participant.id == Assignment.participant_id)
.where(
Participant.user_id == user_id,
Assignment.status == AssignmentStatus.COMPLETED.value
)
)
completed_assignments = completed_result.scalar() or 0
# 4. Всего очков заработано
points_result = await db.execute(
select(func.coalesce(func.sum(Assignment.points_earned), 0))
.join(Participant, Participant.id == Assignment.participant_id)
.where(
Participant.user_id == user_id,
Assignment.status == AssignmentStatus.COMPLETED.value
)
)
total_points_earned = points_result.scalar() or 0
return UserStats(
marathons_count=marathons_count,
wins_count=wins_count,
completed_assignments=completed_assignments,
total_points_earned=total_points_earned,
)

View File

@@ -3,15 +3,13 @@ from datetime import datetime
from fastapi import APIRouter, HTTPException, UploadFile, File, Form from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
import uuid
from pathlib import Path
from app.api.deps import DbSession, CurrentUser from app.api.deps import DbSession, CurrentUser
from app.core.config import settings from app.core.config import settings
from app.models import ( from app.models import (
Marathon, MarathonStatus, Game, Challenge, Participant, Marathon, MarathonStatus, Game, Challenge, Participant,
Assignment, AssignmentStatus, Activity, ActivityType, Assignment, AssignmentStatus, Activity, ActivityType,
EventType, Difficulty EventType, Difficulty, User
) )
from app.schemas import ( from app.schemas import (
SpinResult, AssignmentResponse, CompleteResult, DropResult, SpinResult, AssignmentResponse, CompleteResult, DropResult,
@@ -19,6 +17,7 @@ from app.schemas import (
) )
from app.services.points import PointsService from app.services.points import PointsService
from app.services.events import event_service from app.services.events import event_service
from app.services.storage import storage_service
router = APIRouter(tags=["wheel"]) router = APIRouter(tags=["wheel"])
@@ -38,7 +37,14 @@ async def get_participant_or_403(db, user_id: int, marathon_id: int) -> Particip
return participant return participant
async def get_active_assignment(db, participant_id: int) -> Assignment | None: async def get_active_assignment(db, participant_id: int, is_event: bool = False) -> Assignment | None:
"""Get active assignment for participant.
Args:
db: Database session
participant_id: Participant ID
is_event: If True, get event assignment (Common Enemy). If False, get regular assignment.
"""
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
@@ -47,11 +53,45 @@ async def get_active_assignment(db, participant_id: int) -> Assignment | None:
.where( .where(
Assignment.participant_id == participant_id, Assignment.participant_id == participant_id,
Assignment.status == AssignmentStatus.ACTIVE.value, Assignment.status == AssignmentStatus.ACTIVE.value,
Assignment.is_event_assignment == is_event,
) )
) )
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_oldest_returned_assignment(db, participant_id: int) -> Assignment | None:
"""Get the oldest returned assignment that needs to be redone."""
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(
Assignment.participant_id == participant_id,
Assignment.status == AssignmentStatus.RETURNED.value,
Assignment.is_event_assignment == False,
)
.order_by(Assignment.completed_at.asc()) # Oldest first
.limit(1)
)
return result.scalar_one_or_none()
async def activate_returned_assignment(db, returned_assignment: Assignment) -> None:
"""
Re-activate a returned assignment.
Simply changes the status back to ACTIVE.
"""
returned_assignment.status = AssignmentStatus.ACTIVE.value
returned_assignment.started_at = datetime.utcnow()
# Clear previous proof data for fresh attempt
returned_assignment.proof_path = None
returned_assignment.proof_url = None
returned_assignment.proof_comment = None
returned_assignment.completed_at = None
returned_assignment.points_earned = 0
@router.post("/marathons/{marathon_id}/spin", response_model=SpinResult) @router.post("/marathons/{marathon_id}/spin", response_model=SpinResult)
async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession): async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Spin the wheel to get a random game and challenge""" """Spin the wheel to get a random game and challenge"""
@@ -64,10 +104,14 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
if marathon.status != MarathonStatus.ACTIVE.value: if marathon.status != MarathonStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Marathon is not active") raise HTTPException(status_code=400, detail="Marathon is not active")
# Check if marathon has expired by end_date
if marathon.end_date and datetime.utcnow() > marathon.end_date:
raise HTTPException(status_code=400, detail="Marathon has ended")
participant = await get_participant_or_403(db, current_user.id, marathon_id) participant = await get_participant_or_403(db, current_user.id, marathon_id)
# Check no active assignment # Check no active regular assignment (event assignments are separate)
active = await get_active_assignment(db, participant.id) active = await get_active_assignment(db, participant.id, is_event=False)
if active: if active:
raise HTTPException(status_code=400, detail="You already have an active assignment") raise HTTPException(status_code=400, detail="You already have an active assignment")
@@ -77,7 +121,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
game = None game = None
challenge = None challenge = None
# Handle special event cases # Handle special event cases (excluding Common Enemy - it has separate flow)
if active_event: if active_event:
if active_event.type == EventType.JACKPOT.value: if active_event.type == EventType.JACKPOT.value:
# Jackpot: Get hard challenge only # Jackpot: Get hard challenge only
@@ -90,17 +134,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
game = result.scalar_one_or_none() game = result.scalar_one_or_none()
# Consume jackpot (one-time use) # Consume jackpot (one-time use)
await event_service.consume_jackpot(db, active_event.id) await event_service.consume_jackpot(db, active_event.id)
# Note: Common Enemy is handled separately via event-assignment endpoints
elif active_event.type == EventType.COMMON_ENEMY.value:
# Common enemy: Everyone gets same challenge (if not already completed)
event_data = active_event.data or {}
completions = event_data.get("completions", [])
already_completed = any(c["participant_id"] == participant.id for c in completions)
if not already_completed:
challenge = await event_service.get_common_enemy_challenge(db, active_event)
if challenge:
game = challenge.game
# Normal random selection if no special event handling # Normal random selection if no special event handling
if not game or not challenge: if not game or not challenge:
@@ -130,6 +164,8 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
activity_data = { activity_data = {
"game": game.title, "game": game.title,
"challenge": challenge.title, "challenge": challenge.title,
"difficulty": challenge.difficulty,
"points": challenge.points,
} }
if active_event: if active_event:
activity_data["event_type"] = active_event.type activity_data["event_type"] = active_event.type
@@ -146,7 +182,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
await db.refresh(assignment) await db.refresh(assignment)
# Calculate drop penalty (considers active event for double_risk) # Calculate drop penalty (considers active event for double_risk)
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, active_event) drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, challenge.points, active_event)
# Get challenges count (avoid lazy loading in async context) # Get challenges count (avoid lazy loading in async context)
challenges_count = 0 challenges_count = 0
@@ -162,7 +198,7 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
game=GameResponse( game=GameResponse(
id=game.id, id=game.id,
title=game.title, title=game.title,
cover_url=f"/uploads/covers/{game.cover_path.split('/')[-1]}" if game.cover_path else None, cover_url=storage_service.get_url(game.cover_path, "covers"),
download_url=game.download_url, download_url=game.download_url,
genre=game.genre, genre=game.genre,
added_by=None, added_by=None,
@@ -190,9 +226,9 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
@router.get("/marathons/{marathon_id}/current-assignment", response_model=AssignmentResponse | None) @router.get("/marathons/{marathon_id}/current-assignment", response_model=AssignmentResponse | None)
async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db: DbSession): async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""Get current active assignment""" """Get current active regular assignment (not event assignments)"""
participant = await get_participant_or_403(db, current_user.id, marathon_id) participant = await get_participant_or_403(db, current_user.id, marathon_id)
assignment = await get_active_assignment(db, participant.id) assignment = await get_active_assignment(db, participant.id, is_event=False)
if not assignment: if not assignment:
return None return None
@@ -200,6 +236,10 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
challenge = assignment.challenge challenge = assignment.challenge
game = challenge.game game = challenge.game
# Calculate drop penalty (considers active event for double_risk)
active_event = await event_service.get_active_event(db, marathon_id)
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, challenge.points, active_event)
return AssignmentResponse( return AssignmentResponse(
id=assignment.id, id=assignment.id,
challenge=ChallengeResponse( challenge=ChallengeResponse(
@@ -217,12 +257,13 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
created_at=challenge.created_at, created_at=challenge.created_at,
), ),
status=assignment.status, status=assignment.status,
proof_url=f"/uploads/proofs/{assignment.proof_path.split('/')[-1]}" if assignment.proof_path else assignment.proof_url, proof_url=storage_service.get_url(assignment.proof_path, "proofs") if assignment.proof_path else assignment.proof_url,
proof_comment=assignment.proof_comment, proof_comment=assignment.proof_comment,
points_earned=assignment.points_earned, points_earned=assignment.points_earned,
streak_at_completion=assignment.streak_at_completion, streak_at_completion=assignment.streak_at_completion,
started_at=assignment.started_at, started_at=assignment.started_at,
completed_at=assignment.completed_at, completed_at=assignment.completed_at,
drop_penalty=drop_penalty,
) )
@@ -235,7 +276,7 @@ async def complete_assignment(
comment: str | None = Form(None), comment: str | None = Form(None),
proof_file: UploadFile | None = File(None), proof_file: UploadFile | None = File(None),
): ):
"""Complete an assignment with proof""" """Complete a regular assignment with proof (not event assignments)"""
# Get assignment # Get assignment
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
@@ -256,6 +297,10 @@ async def complete_assignment(
if assignment.status != AssignmentStatus.ACTIVE.value: if assignment.status != AssignmentStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail="Assignment is not active") raise HTTPException(status_code=400, detail="Assignment is not active")
# Event assignments should be completed via /event-assignments/{id}/complete
if assignment.is_event_assignment:
raise HTTPException(status_code=400, detail="Use /event-assignments/{id}/complete for event assignments")
# Need either file or URL # Need either file or URL
if not proof_file and not proof_url: if not proof_file and not proof_url:
raise HTTPException(status_code=400, detail="Proof is required (file or URL)") raise HTTPException(status_code=400, detail="Proof is required (file or URL)")
@@ -276,14 +321,16 @@ async def complete_assignment(
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}", detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}",
) )
filename = f"{assignment_id}_{uuid.uuid4().hex}.{ext}" # Upload file to storage
filepath = Path(settings.UPLOAD_DIR) / "proofs" / filename filename = storage_service.generate_filename(assignment_id, proof_file.filename)
filepath.parent.mkdir(parents=True, exist_ok=True) file_path = await storage_service.upload_file(
content=contents,
folder="proofs",
filename=filename,
content_type=proof_file.content_type or "application/octet-stream",
)
with open(filepath, "wb") as f: assignment.proof_path = file_path
f.write(contents)
assignment.proof_path = str(filepath)
else: else:
assignment.proof_url = proof_url assignment.proof_url = proof_url
@@ -303,12 +350,12 @@ async def complete_assignment(
# Check active event for point multipliers # Check active event for point multipliers
active_event = await event_service.get_active_event(db, marathon_id) active_event = await event_service.get_active_event(db, marathon_id)
# For jackpot/rematch: use the event_type stored in assignment (since event may be over) # For jackpot: use the event_type stored in assignment (since event may be over)
# For other events: use the currently active event # For other events: use the currently active event
effective_event = active_event effective_event = active_event
# Handle assignment-level event types (jackpot, rematch) # Handle assignment-level event types (jackpot)
if assignment.event_type in [EventType.JACKPOT.value, EventType.REMATCH.value]: if assignment.event_type == EventType.JACKPOT.value:
# Create a mock event object for point calculation # Create a mock event object for point calculation
class MockEvent: class MockEvent:
def __init__(self, event_type): def __init__(self, event_type):
@@ -328,6 +375,7 @@ async def complete_assignment(
db, active_event, participant.id, current_user.id db, active_event, participant.id, current_user.id
) )
total_points += common_enemy_bonus total_points += common_enemy_bonus
print(f"[COMMON_ENEMY] bonus={common_enemy_bonus}, closed={common_enemy_closed}, winners={common_enemy_winners}")
# Update assignment # Update assignment
assignment.status = AssignmentStatus.COMPLETED.value assignment.status = AssignmentStatus.COMPLETED.value
@@ -342,12 +390,15 @@ async def complete_assignment(
# Log activity # Log activity
activity_data = { activity_data = {
"assignment_id": assignment.id,
"game": full_challenge.game.title,
"challenge": challenge.title, "challenge": challenge.title,
"difficulty": challenge.difficulty,
"points": total_points, "points": total_points,
"streak": participant.current_streak, "streak": participant.current_streak,
} }
# Log event info (use assignment's event_type for jackpot/rematch, active_event for others) # Log event info (use assignment's event_type for jackpot, active_event for others)
if assignment.event_type in [EventType.JACKPOT.value, EventType.REMATCH.value]: if assignment.event_type == EventType.JACKPOT.value:
activity_data["event_type"] = assignment.event_type activity_data["event_type"] = assignment.event_type
activity_data["event_bonus"] = event_bonus activity_data["event_bonus"] = event_bonus
elif active_event: elif active_event:
@@ -367,6 +418,24 @@ async def complete_assignment(
# If common enemy event auto-closed, log the event end with winners # If common enemy event auto-closed, log the event end with winners
if common_enemy_closed and common_enemy_winners: if common_enemy_closed and common_enemy_winners:
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
# Load winner nicknames
winner_user_ids = [w["user_id"] for w in common_enemy_winners]
users_result = await db.execute(
select(User).where(User.id.in_(winner_user_ids))
)
users_map = {u.id: u.nickname for u in users_result.scalars().all()}
winners_data = [
{
"user_id": w["user_id"],
"nickname": users_map.get(w["user_id"], "Unknown"),
"rank": w["rank"],
"bonus_points": COMMON_ENEMY_BONUSES.get(w["rank"], 0),
}
for w in common_enemy_winners
]
print(f"[COMMON_ENEMY] Creating event_end activity with winners: {winners_data}")
event_end_activity = Activity( event_end_activity = Activity(
marathon_id=marathon_id, marathon_id=marathon_id,
user_id=current_user.id, # Last completer triggers the close user_id=current_user.id, # Last completer triggers the close
@@ -375,20 +444,20 @@ async def complete_assignment(
"event_type": EventType.COMMON_ENEMY.value, "event_type": EventType.COMMON_ENEMY.value,
"event_name": EVENT_INFO.get(EventType.COMMON_ENEMY, {}).get("name", "Общий враг"), "event_name": EVENT_INFO.get(EventType.COMMON_ENEMY, {}).get("name", "Общий враг"),
"auto_closed": True, "auto_closed": True,
"winners": [ "winners": winners_data,
{
"user_id": w["user_id"],
"rank": w["rank"],
"bonus_points": COMMON_ENEMY_BONUSES.get(w["rank"], 0),
}
for w in common_enemy_winners
],
}, },
) )
db.add(event_end_activity) db.add(event_end_activity)
await db.commit() await db.commit()
# Check for returned assignments and activate the oldest one
returned_assignment = await get_oldest_returned_assignment(db, participant.id)
if returned_assignment:
await activate_returned_assignment(db, returned_assignment)
await db.commit()
print(f"[WHEEL] Auto-activated returned assignment {returned_assignment.id} for participant {participant.id}")
return CompleteResult( return CompleteResult(
points_earned=total_points, points_earned=total_points,
streak_bonus=streak_bonus, streak_bonus=streak_bonus,
@@ -427,7 +496,7 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
active_event = await event_service.get_active_event(db, marathon_id) active_event = await event_service.get_active_event(db, marathon_id)
# Calculate penalty (0 if double_risk event is active) # Calculate penalty (0 if double_risk event is active)
penalty = points_service.calculate_drop_penalty(participant.drop_count, active_event) penalty = points_service.calculate_drop_penalty(participant.drop_count, assignment.challenge.points, active_event)
# Update assignment # Update assignment
assignment.status = AssignmentStatus.DROPPED.value assignment.status = AssignmentStatus.DROPPED.value
@@ -440,7 +509,9 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
# Log activity # Log activity
activity_data = { activity_data = {
"game": assignment.challenge.game.title,
"challenge": assignment.challenge.title, "challenge": assignment.challenge.title,
"difficulty": assignment.challenge.difficulty,
"penalty": penalty, "penalty": penalty,
} }
if active_event: if active_event:
@@ -510,7 +581,7 @@ async def get_my_history(
created_at=a.challenge.created_at, created_at=a.challenge.created_at,
), ),
status=a.status, status=a.status,
proof_url=f"/uploads/proofs/{a.proof_path.split('/')[-1]}" if a.proof_path else a.proof_url, proof_url=storage_service.get_url(a.proof_path, "proofs") if a.proof_path else a.proof_url,
proof_comment=a.proof_comment, proof_comment=a.proof_comment,
points_earned=a.points_earned, points_earned=a.points_earned,
streak_at_completion=a.streak_at_completion, streak_at_completion=a.streak_at_completion,

View File

@@ -20,13 +20,30 @@ class Settings(BaseSettings):
# Telegram # Telegram
TELEGRAM_BOT_TOKEN: str = "" TELEGRAM_BOT_TOKEN: str = ""
TELEGRAM_BOT_USERNAME: str = ""
TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES: int = 10
BOT_API_SECRET: str = "" # Secret key for bot-to-backend communication
# Frontend
FRONTEND_URL: str = "http://localhost:3000"
# Uploads # Uploads
UPLOAD_DIR: str = "uploads" UPLOAD_DIR: str = "uploads"
MAX_UPLOAD_SIZE: int = 15 * 1024 * 1024 # 15 MB MAX_UPLOAD_SIZE: int = 5 * 1024 * 1024 # 5 MB for avatars
MAX_IMAGE_SIZE: int = 15 * 1024 * 1024 # 15 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"}
ALLOWED_VIDEO_EXTENSIONS: set = {"mp4", "webm", "mov"} ALLOWED_VIDEO_EXTENSIONS: set = {"mp4", "webm", "mov"}
# S3 Storage (FirstVDS)
S3_ENABLED: bool = False
S3_BUCKET_NAME: str = ""
S3_REGION: str = "ru-1"
S3_ACCESS_KEY_ID: str = ""
S3_SECRET_ACCESS_KEY: str = ""
S3_ENDPOINT_URL: str = ""
S3_PUBLIC_URL: str = ""
@property @property
def ALLOWED_EXTENSIONS(self) -> set: def ALLOWED_EXTENSIONS(self) -> set:
return self.ALLOWED_IMAGE_EXTENSIONS | self.ALLOWED_VIDEO_EXTENSIONS return self.ALLOWED_IMAGE_EXTENSIONS | self.ALLOWED_VIDEO_EXTENSIONS

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,3 +1,8 @@
import base64
import hashlib
import hmac
import struct
import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any from typing import Any
@@ -35,3 +40,71 @@ def decode_access_token(token: str) -> dict | None:
return payload return payload
except jwt.JWTError: except jwt.JWTError:
return None return None
def create_telegram_link_token(user_id: int, expire_minutes: int = 10) -> str:
"""
Create a short token for Telegram account linking.
Format: base64url encoded binary data (no separators).
Structure: user_id (4 bytes) + expire_at (4 bytes) + signature (8 bytes) = 16 bytes -> 22 chars base64url.
"""
expire_at = int(time.time()) + (expire_minutes * 60)
# Pack user_id and expire_at as unsigned 32-bit integers (8 bytes total)
data = struct.pack(">II", user_id, expire_at)
# Create HMAC signature (take first 8 bytes)
signature = hmac.new(
settings.SECRET_KEY.encode(),
data,
hashlib.sha256
).digest()[:8]
# Combine data + signature (16 bytes)
token_bytes = data + signature
# Encode as base64url without padding
token = base64.urlsafe_b64encode(token_bytes).decode().rstrip("=")
return token
def verify_telegram_link_token(token: str) -> int | None:
"""
Verify Telegram link token and return user_id if valid.
Returns None if token is invalid or expired.
"""
try:
# Add padding if needed for base64 decoding
padding = 4 - (len(token) % 4)
if padding != 4:
token += "=" * padding
token_bytes = base64.urlsafe_b64decode(token)
if len(token_bytes) != 16:
return None
# Unpack data
data = token_bytes[:8]
provided_signature = token_bytes[8:]
user_id, expire_at = struct.unpack(">II", data)
# Check expiration
if time.time() > expire_at:
return None
# Verify signature
expected_signature = hmac.new(
settings.SECRET_KEY.encode(),
data,
hashlib.sha256
).digest()[:8]
if not hmac.compare_digest(provided_signature, expected_signature):
return None
return user_id
except (ValueError, struct.error, Exception):
return None

View File

@@ -1,14 +1,26 @@
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
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pathlib import Path 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
@asynccontextmanager @asynccontextmanager
@@ -23,13 +35,15 @@ async def lifespan(app: FastAPI):
(upload_dir / "covers").mkdir(parents=True, exist_ok=True) (upload_dir / "covers").mkdir(parents=True, exist_ok=True)
(upload_dir / "proofs").mkdir(parents=True, exist_ok=True) (upload_dir / "proofs").mkdir(parents=True, exist_ok=True)
# Start event scheduler # Start schedulers
await event_scheduler.start(async_session_maker) await event_scheduler.start(async_session_maker)
await dispute_scheduler.start(async_session_maker)
yield yield
# Shutdown # Shutdown
await event_scheduler.stop() await event_scheduler.stop()
await dispute_scheduler.stop()
await engine.dispose() await engine.dispose()
@@ -39,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

@@ -7,6 +7,7 @@ from app.models.assignment import Assignment, AssignmentStatus
from app.models.activity import Activity, ActivityType 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
__all__ = [ __all__ = [
"User", "User",
@@ -30,4 +31,8 @@ __all__ = [
"EventType", "EventType",
"SwapRequest", "SwapRequest",
"SwapRequestStatus", "SwapRequestStatus",
"Dispute",
"DisputeStatus",
"DisputeComment",
"DisputeVote",
] ]

View File

@@ -19,7 +19,7 @@ class ActivityType(str, Enum):
EVENT_START = "event_start" EVENT_START = "event_start"
EVENT_END = "event_end" EVENT_END = "event_end"
SWAP = "swap" SWAP = "swap"
REMATCH = "rematch" GAME_CHOICE = "game_choice"
class Activity(Base): class Activity(Base):

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, Text, DateTime, ForeignKey, Integer from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Boolean
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
@@ -10,6 +10,7 @@ class AssignmentStatus(str, Enum):
ACTIVE = "active" ACTIVE = "active"
COMPLETED = "completed" COMPLETED = "completed"
DROPPED = "dropped" DROPPED = "dropped"
RETURNED = "returned" # Disputed and needs to be redone
class Assignment(Base): class Assignment(Base):
@@ -20,6 +21,8 @@ class Assignment(Base):
challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE")) challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"))
status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value) status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value)
event_type: Mapped[str | None] = mapped_column(String(30), nullable=True) # Event type when assignment was created event_type: Mapped[str | None] = mapped_column(String(30), nullable=True) # Event type when assignment was created
is_event_assignment: Mapped[bool] = mapped_column(Boolean, default=False, index=True) # True for Common Enemy assignments
event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id", ondelete="SET NULL"), nullable=True, index=True) # Link to event
proof_path: Mapped[str | None] = mapped_column(String(500), nullable=True) proof_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
proof_url: Mapped[str | None] = mapped_column(Text, nullable=True) proof_url: Mapped[str | None] = mapped_column(Text, nullable=True)
proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True) proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -31,3 +34,5 @@ class Assignment(Base):
# Relationships # Relationships
participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments") participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments")
challenge: Mapped["Challenge"] = relationship("Challenge", back_populates="assignments") challenge: Mapped["Challenge"] = relationship("Challenge", back_populates="assignments")
event: Mapped["Event | None"] = relationship("Event", back_populates="assignments")
dispute: Mapped["Dispute | None"] = relationship("Dispute", back_populates="assignment", uselist=False, cascade="all, delete-orphan", passive_deletes=True)

View File

@@ -0,0 +1,66 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class DisputeStatus(str, Enum):
OPEN = "open"
RESOLVED_VALID = "valid"
RESOLVED_INVALID = "invalid"
class Dispute(Base):
"""Dispute against a completed assignment's proof"""
__tablename__ = "disputes"
id: Mapped[int] = mapped_column(primary_key=True)
assignment_id: Mapped[int] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), unique=True, index=True)
raised_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
reason: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(String(20), default=DisputeStatus.OPEN.value)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Relationships
assignment: Mapped["Assignment"] = relationship("Assignment", back_populates="dispute")
raised_by: Mapped["User"] = relationship("User", foreign_keys=[raised_by_id])
comments: Mapped[list["DisputeComment"]] = relationship("DisputeComment", back_populates="dispute", cascade="all, delete-orphan")
votes: Mapped[list["DisputeVote"]] = relationship("DisputeVote", back_populates="dispute", cascade="all, delete-orphan")
class DisputeComment(Base):
"""Comment in a dispute discussion"""
__tablename__ = "dispute_comments"
id: Mapped[int] = mapped_column(primary_key=True)
dispute_id: Mapped[int] = mapped_column(ForeignKey("disputes.id", ondelete="CASCADE"), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
text: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
dispute: Mapped["Dispute"] = relationship("Dispute", back_populates="comments")
user: Mapped["User"] = relationship("User")
class DisputeVote(Base):
"""Vote in a dispute (valid or invalid)"""
__tablename__ = "dispute_votes"
id: Mapped[int] = mapped_column(primary_key=True)
dispute_id: Mapped[int] = mapped_column(ForeignKey("disputes.id", ondelete="CASCADE"), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
vote: Mapped[bool] = mapped_column(Boolean, nullable=False) # True = valid, False = invalid
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Unique constraint: one vote per user per dispute
__table_args__ = (
UniqueConstraint("dispute_id", "user_id", name="uq_dispute_vote_user"),
)
# Relationships
dispute: Mapped["Dispute"] = relationship("Dispute", back_populates="votes")
user: Mapped["User"] = relationship("User")

View File

@@ -12,7 +12,7 @@ class EventType(str, Enum):
DOUBLE_RISK = "double_risk" # дропы бесплатны, x0.5 очков DOUBLE_RISK = "double_risk" # дропы бесплатны, x0.5 очков
JACKPOT = "jackpot" # x3 за сложный челлендж JACKPOT = "jackpot" # x3 за сложный челлендж
SWAP = "swap" # обмен заданиями SWAP = "swap" # обмен заданиями
REMATCH = "rematch" # реванш проваленного GAME_CHOICE = "game_choice" # выбор игры (2-3 челленджа на выбор)
class Event(Base): class Event(Base):
@@ -37,3 +37,4 @@ class Event(Base):
# Relationships # Relationships
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="events") marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="events")
created_by: Mapped["User | None"] = relationship("User") created_by: Mapped["User | None"] = relationship("User")
assignments: Mapped[list["Assignment"]] = relationship("Assignment", back_populates="event")

View File

@@ -21,6 +21,9 @@ class User(Base):
avatar_path: Mapped[str | None] = mapped_column(String(500), nullable=True) avatar_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, nullable=True) telegram_id: Mapped[int | None] = mapped_column(BigInteger, unique=True, nullable=True)
telegram_username: Mapped[str | None] = mapped_column(String(50), nullable=True) telegram_username: Mapped[str | None] = mapped_column(String(50), nullable=True)
telegram_first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
telegram_last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
telegram_avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
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)
@@ -52,5 +55,7 @@ class User(Base):
@property @property
def avatar_url(self) -> str | None: def avatar_url(self) -> str | None:
if self.avatar_path: if self.avatar_path:
return f"/uploads/avatars/{self.avatar_path.split('/')[-1]}" # Lazy import to avoid circular dependency
from app.services.storage import storage_service
return storage_service.get_url(self.avatar_path, "avatars")
return None return None

View File

@@ -3,9 +3,12 @@ from app.schemas.user import (
UserLogin, UserLogin,
UserUpdate, UserUpdate,
UserPublic, UserPublic,
UserWithTelegram, UserPrivate,
TokenResponse, TokenResponse,
TelegramLink, TelegramLink,
PasswordChange,
UserStats,
UserProfilePublic,
) )
from app.schemas.marathon import ( from app.schemas.marathon import (
MarathonCreate, MarathonCreate,
@@ -34,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,
@@ -41,6 +45,7 @@ from app.schemas.assignment import (
SpinResult, SpinResult,
CompleteResult, CompleteResult,
DropResult, DropResult,
EventAssignmentResponse,
) )
from app.schemas.activity import ( from app.schemas.activity import (
ActivityResponse, ActivityResponse,
@@ -66,6 +71,16 @@ from app.schemas.common import (
ErrorResponse, ErrorResponse,
PaginationParams, PaginationParams,
) )
from app.schemas.dispute import (
DisputeCreate,
DisputeCommentCreate,
DisputeVoteCreate,
DisputeCommentResponse,
DisputeVoteResponse,
DisputeResponse,
AssignmentDetailResponse,
ReturnedAssignmentResponse,
)
__all__ = [ __all__ = [
# User # User
@@ -73,9 +88,12 @@ __all__ = [
"UserLogin", "UserLogin",
"UserUpdate", "UserUpdate",
"UserPublic", "UserPublic",
"UserWithTelegram", "UserPrivate",
"TokenResponse", "TokenResponse",
"TelegramLink", "TelegramLink",
"PasswordChange",
"UserStats",
"UserProfilePublic",
# Marathon # Marathon
"MarathonCreate", "MarathonCreate",
"MarathonUpdate", "MarathonUpdate",
@@ -101,12 +119,14 @@ __all__ = [
"ChallengesPreviewResponse", "ChallengesPreviewResponse",
"ChallengeSaveItem", "ChallengeSaveItem",
"ChallengesSaveRequest", "ChallengesSaveRequest",
"ChallengesGenerateRequest",
# Assignment # Assignment
"CompleteAssignment", "CompleteAssignment",
"AssignmentResponse", "AssignmentResponse",
"SpinResult", "SpinResult",
"CompleteResult", "CompleteResult",
"DropResult", "DropResult",
"EventAssignmentResponse",
# Activity # Activity
"ActivityResponse", "ActivityResponse",
"FeedResponse", "FeedResponse",
@@ -128,4 +148,13 @@ __all__ = [
"MessageResponse", "MessageResponse",
"ErrorResponse", "ErrorResponse",
"PaginationParams", "PaginationParams",
# Dispute
"DisputeCreate",
"DisputeCommentCreate",
"DisputeVoteCreate",
"DisputeCommentResponse",
"DisputeVoteResponse",
"DisputeResponse",
"AssignmentDetailResponse",
"ReturnedAssignmentResponse",
] ]

View File

@@ -24,6 +24,7 @@ class AssignmentResponse(BaseModel):
streak_at_completion: int | None = None streak_at_completion: int | None = None
started_at: datetime started_at: datetime
completed_at: datetime | None = None completed_at: datetime | None = None
drop_penalty: int = 0 # Calculated penalty if dropped
class Config: class Config:
from_attributes = True from_attributes = True
@@ -48,3 +49,14 @@ class DropResult(BaseModel):
penalty: int penalty: int
total_points: int total_points: int
new_drop_count: int new_drop_count: int
class EventAssignmentResponse(BaseModel):
"""Response for event-specific assignment (Common Enemy)"""
assignment: AssignmentResponse | None
event_id: int | None
challenge_id: int | None
is_completed: bool
class Config:
from_attributes = True

View File

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

View File

@@ -0,0 +1,91 @@
from datetime import datetime
from pydantic import BaseModel, Field
from app.schemas.user import UserPublic
from app.schemas.challenge import ChallengeResponse
class DisputeCreate(BaseModel):
"""Request to create a dispute"""
reason: str = Field(..., min_length=10, max_length=1000)
class DisputeCommentCreate(BaseModel):
"""Request to add a comment to a dispute"""
text: str = Field(..., min_length=1, max_length=500)
class DisputeVoteCreate(BaseModel):
"""Request to vote on a dispute"""
vote: bool # True = valid (proof is OK), False = invalid (proof is not OK)
class DisputeCommentResponse(BaseModel):
"""Comment in a dispute discussion"""
id: int
user: UserPublic
text: str
created_at: datetime
class Config:
from_attributes = True
class DisputeVoteResponse(BaseModel):
"""Vote in a dispute"""
user: UserPublic
vote: bool # True = valid, False = invalid
created_at: datetime
class Config:
from_attributes = True
class DisputeResponse(BaseModel):
"""Full dispute information"""
id: int
raised_by: UserPublic
reason: str
status: str # "open", "valid", "invalid"
comments: list[DisputeCommentResponse]
votes: list[DisputeVoteResponse]
votes_valid: int
votes_invalid: int
my_vote: bool | None # Current user's vote, None if not voted
expires_at: datetime
created_at: datetime
resolved_at: datetime | None
class Config:
from_attributes = True
class AssignmentDetailResponse(BaseModel):
"""Detailed assignment information with proofs and dispute"""
id: int
challenge: ChallengeResponse
participant: UserPublic
status: str
proof_url: str | None # External URL (YouTube, etc.)
proof_image_url: str | None # Uploaded file URL
proof_comment: str | None
points_earned: int
streak_at_completion: int | None
started_at: datetime
completed_at: datetime | None
can_dispute: bool # True if <24h since completion and not own assignment
dispute: DisputeResponse | None
class Config:
from_attributes = True
class ReturnedAssignmentResponse(BaseModel):
"""Returned assignment that needs to be redone"""
id: int
challenge: ChallengeResponse
original_completed_at: datetime
dispute_reason: str
class Config:
from_attributes = True

View File

@@ -13,7 +13,7 @@ EventTypeLiteral = Literal[
"double_risk", "double_risk",
"jackpot", "jackpot",
"swap", "swap",
"rematch", "game_choice",
] ]
@@ -32,7 +32,7 @@ class EventCreate(BaseModel):
class EventEffects(BaseModel): class EventEffects(BaseModel):
points_multiplier: float = 1.0 points_multiplier: float = 1.0
drop_free: bool = False drop_free: bool = False
special_action: str | None = None # "swap", "rematch" special_action: str | None = None # "swap", "game_choice"
description: str = "" description: str = ""
@@ -85,7 +85,7 @@ EVENT_INFO = {
"drop_free": False, "drop_free": False,
}, },
EventType.DOUBLE_RISK: { EventType.DOUBLE_RISK: {
"name": "Двойной риск", "name": "Безопасная игра",
"description": "Дропы бесплатны, но очки x0.5", "description": "Дропы бесплатны, но очки x0.5",
"default_duration": 120, "default_duration": 120,
"points_multiplier": 0.5, "points_multiplier": 0.5,
@@ -106,13 +106,13 @@ EVENT_INFO = {
"drop_free": False, "drop_free": False,
"special_action": "swap", "special_action": "swap",
}, },
EventType.REMATCH: { EventType.GAME_CHOICE: {
"name": "Реванш", "name": "Выбор игры",
"description": "Можно переделать проваленный челлендж за 50% очков", "description": "Выбери игру и один из 3 челленджей. Можно заменить текущее задание без штрафа!",
"default_duration": 240, "default_duration": 120,
"points_multiplier": 0.5, "points_multiplier": 1.0,
"drop_free": False, "drop_free": True, # Free replacement of current assignment
"special_action": "rematch", "special_action": "game_choice",
}, },
} }

View File

@@ -29,27 +29,57 @@ 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_avatar_url: str | None = None # Only TG avatar is public
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):
telegram_id: int telegram_id: int
telegram_username: str | None = None telegram_username: str | None = None
class PasswordChange(BaseModel):
current_password: str = Field(..., min_length=6)
new_password: str = Field(..., min_length=6, max_length=100)
class UserStats(BaseModel):
"""Статистика пользователя по марафонам"""
marathons_count: int
wins_count: int
completed_assignments: int
total_points_earned: int
class UserProfilePublic(BaseModel):
"""Публичный профиль пользователя со статистикой"""
id: int
nickname: str
avatar_url: str | None = None
created_at: datetime
stats: UserStats
class Config:
from_attributes = True

View File

@@ -0,0 +1,89 @@
"""
Dispute Scheduler for automatic dispute resolution after 24 hours.
"""
import asyncio
from datetime import datetime, timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import Dispute, DisputeStatus, Assignment, AssignmentStatus
from app.services.disputes import dispute_service
# Configuration
CHECK_INTERVAL_SECONDS = 300 # Check every 5 minutes
DISPUTE_WINDOW_HOURS = 24 # Disputes auto-resolve after 24 hours
class DisputeScheduler:
"""Background scheduler for automatic dispute resolution."""
def __init__(self):
self._running = False
self._task: asyncio.Task | None = None
async def start(self, session_factory) -> None:
"""Start the scheduler background task."""
if self._running:
return
self._running = True
self._task = asyncio.create_task(self._run_loop(session_factory))
print("[DisputeScheduler] Started")
async def stop(self) -> None:
"""Stop the scheduler."""
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
print("[DisputeScheduler] Stopped")
async def _run_loop(self, session_factory) -> None:
"""Main scheduler loop."""
while self._running:
try:
async with session_factory() as db:
await self._process_expired_disputes(db)
except Exception as e:
print(f"[DisputeScheduler] Error in loop: {e}")
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
async def _process_expired_disputes(self, db: AsyncSession) -> None:
"""Process and resolve expired disputes."""
cutoff_time = datetime.utcnow() - timedelta(hours=DISPUTE_WINDOW_HOURS)
# Find all open disputes that have expired
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.votes),
selectinload(Dispute.assignment).selectinload(Assignment.participant),
)
.where(
Dispute.status == DisputeStatus.OPEN.value,
Dispute.created_at < cutoff_time,
)
)
expired_disputes = result.scalars().all()
for dispute in expired_disputes:
try:
result_status, votes_valid, votes_invalid = await dispute_service.resolve_dispute(
db, dispute.id
)
print(
f"[DisputeScheduler] Auto-resolved dispute {dispute.id}: "
f"{result_status} (valid: {votes_valid}, invalid: {votes_invalid})"
)
except Exception as e:
print(f"[DisputeScheduler] Failed to resolve dispute {dispute.id}: {e}")
# Global scheduler instance
dispute_scheduler = DisputeScheduler()

View File

@@ -0,0 +1,149 @@
"""
Dispute resolution service.
"""
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import (
Dispute, DisputeStatus, DisputeVote,
Assignment, AssignmentStatus, Participant, Marathon, Challenge, Game,
)
from app.services.telegram_notifier import telegram_notifier
class DisputeService:
"""Service for dispute resolution logic"""
async def resolve_dispute(self, db: AsyncSession, dispute_id: int) -> tuple[str, int, int]:
"""
Resolve a dispute based on votes.
Returns:
Tuple of (result_status, votes_valid, votes_invalid)
"""
# Get dispute with votes and assignment
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.votes),
selectinload(Dispute.assignment).selectinload(Assignment.participant),
)
.where(Dispute.id == dispute_id)
)
dispute = result.scalar_one_or_none()
if not dispute:
raise ValueError(f"Dispute {dispute_id} not found")
if dispute.status != DisputeStatus.OPEN.value:
raise ValueError(f"Dispute {dispute_id} is already resolved")
# Count votes
votes_valid = sum(1 for v in dispute.votes if v.vote is True)
votes_invalid = sum(1 for v in dispute.votes if v.vote is False)
# Determine result: tie goes to the accused (valid)
if votes_invalid > votes_valid:
# Proof is invalid - mark assignment as RETURNED
result_status = DisputeStatus.RESOLVED_INVALID.value
await self._handle_invalid_proof(db, dispute)
else:
# Proof is valid (or tie)
result_status = DisputeStatus.RESOLVED_VALID.value
# Update dispute
dispute.status = result_status
dispute.resolved_at = datetime.utcnow()
await db.commit()
# Send Telegram notification about dispute resolution
await self._notify_dispute_resolved(db, dispute, result_status == DisputeStatus.RESOLVED_INVALID.value)
return result_status, votes_valid, votes_invalid
async def _notify_dispute_resolved(
self,
db: AsyncSession,
dispute: Dispute,
is_valid: bool
) -> None:
"""Send notification about dispute resolution to the assignment owner."""
try:
# Get assignment with challenge and marathon info
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.participant),
selectinload(Assignment.challenge).selectinload(Challenge.game)
)
.where(Assignment.id == dispute.assignment_id)
)
assignment = result.scalar_one_or_none()
if not assignment:
return
participant = assignment.participant
challenge = assignment.challenge
game = challenge.game if challenge else None
# Get marathon
result = await db.execute(
select(Marathon).where(Marathon.id == game.marathon_id if game else 0)
)
marathon = result.scalar_one_or_none()
if marathon and participant:
await telegram_notifier.notify_dispute_resolved(
db,
user_id=participant.user_id,
marathon_title=marathon.title,
challenge_title=challenge.title if challenge else "Unknown",
is_valid=is_valid
)
except Exception as e:
print(f"[DisputeService] Failed to send notification: {e}")
async def _handle_invalid_proof(self, db: AsyncSession, dispute: Dispute) -> None:
"""
Handle the case when proof is determined to be invalid.
- Mark assignment as RETURNED
- Subtract points from participant
- Reset streak if it was affected
"""
assignment = dispute.assignment
participant = assignment.participant
# Subtract points that were earned
points_to_subtract = assignment.points_earned
participant.total_points = max(0, participant.total_points - points_to_subtract)
# Reset assignment
assignment.status = AssignmentStatus.RETURNED.value
assignment.points_earned = 0
# Keep proof data so it can be reviewed
print(f"[DisputeService] Assignment {assignment.id} marked as RETURNED, "
f"subtracted {points_to_subtract} points from participant {participant.id}")
async def get_pending_disputes(self, db: AsyncSession, older_than_hours: int = 24) -> list[Dispute]:
"""Get all open disputes older than specified hours"""
from datetime import timedelta
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
result = await db.execute(
select(Dispute)
.where(
Dispute.status == DisputeStatus.OPEN.value,
Dispute.created_at < cutoff_time,
)
)
return list(result.scalars().all())
# Global service instance
dispute_service = DisputeService()

View File

@@ -21,7 +21,7 @@ AUTO_EVENT_TYPES = [
EventType.GOLDEN_HOUR, EventType.GOLDEN_HOUR,
EventType.DOUBLE_RISK, EventType.DOUBLE_RISK,
EventType.JACKPOT, EventType.JACKPOT,
EventType.REMATCH, EventType.GAME_CHOICE,
] ]

View File

@@ -4,8 +4,9 @@ from sqlalchemy.orm import selectinload
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Event, EventType, Marathon, Challenge, Difficulty from app.models import Event, EventType, Marathon, Challenge, Difficulty, Participant, Assignment, AssignmentStatus
from app.schemas.event import EventEffects, EVENT_INFO, COMMON_ENEMY_BONUSES from app.schemas.event import EventEffects, EVENT_INFO, COMMON_ENEMY_BONUSES
from app.services.telegram_notifier import telegram_notifier
class EventService: class EventService:
@@ -76,6 +77,12 @@ class EventService:
data=data if data else None, data=data if data else None,
) )
db.add(event) db.add(event)
await db.flush() # Get event.id before committing
# Auto-assign challenge to all participants for Common Enemy
if event_type == EventType.COMMON_ENEMY.value and challenge_id:
await self._assign_common_enemy_to_all(db, marathon_id, event.id, challenge_id)
await db.commit() await db.commit()
await db.refresh(event) await db.refresh(event)
@@ -83,18 +90,81 @@ class EventService:
if created_by_id: if created_by_id:
await db.refresh(event, ["created_by"]) await db.refresh(event, ["created_by"])
# Send Telegram notifications
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if marathon:
await telegram_notifier.notify_event_start(
db, marathon_id, event_type, marathon.title
)
return event return event
async def _assign_common_enemy_to_all(
self,
db: AsyncSession,
marathon_id: int,
event_id: int,
challenge_id: int,
) -> None:
"""Create event assignments for all participants in the marathon"""
# Get all participants
result = await db.execute(
select(Participant).where(Participant.marathon_id == marathon_id)
)
participants = result.scalars().all()
# Create event assignment for each participant
for participant in participants:
assignment = Assignment(
participant_id=participant.id,
challenge_id=challenge_id,
status=AssignmentStatus.ACTIVE.value,
event_type=EventType.COMMON_ENEMY.value,
is_event_assignment=True,
event_id=event_id,
)
db.add(assignment)
async def end_event(self, db: AsyncSession, event_id: int) -> None: async def end_event(self, db: AsyncSession, event_id: int) -> None:
"""End an event""" """End an event and mark incomplete event assignments as expired"""
from sqlalchemy import update
result = await db.execute(select(Event).where(Event.id == event_id)) result = await db.execute(select(Event).where(Event.id == event_id))
event = result.scalar_one_or_none() event = result.scalar_one_or_none()
if event: if event:
event_type = event.type
marathon_id = event.marathon_id
event.is_active = False event.is_active = False
if not event.end_time: if not event.end_time:
event.end_time = datetime.utcnow() event.end_time = datetime.utcnow()
# Mark all incomplete event assignments for this event as dropped
if event.type == EventType.COMMON_ENEMY.value:
await db.execute(
update(Assignment)
.where(
Assignment.event_id == event_id,
Assignment.is_event_assignment == True,
Assignment.status == AssignmentStatus.ACTIVE.value,
)
.values(
status=AssignmentStatus.DROPPED.value,
completed_at=datetime.utcnow(),
)
)
await db.commit() await db.commit()
# Send Telegram notifications about event end
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
if marathon:
await telegram_notifier.notify_event_end(
db, marathon_id, event_type, marathon.title
)
async def consume_jackpot(self, db: AsyncSession, event_id: int) -> None: async def consume_jackpot(self, db: AsyncSession, event_id: int) -> None:
"""Consume jackpot event after one spin""" """Consume jackpot event after one spin"""
await self.end_event(db, event_id) await self.end_event(db, event_id)
@@ -157,13 +227,16 @@ class EventService:
- winners_list: list of winners if event closed, None otherwise - winners_list: list of winners if event closed, None otherwise
""" """
if event.type != EventType.COMMON_ENEMY.value: if event.type != EventType.COMMON_ENEMY.value:
print(f"[COMMON_ENEMY] Event type mismatch: {event.type}")
return 0, False, None return 0, False, None
data = event.data or {} data = event.data or {}
completions = data.get("completions", []) completions = data.get("completions", [])
print(f"[COMMON_ENEMY] Current completions count: {len(completions)}")
# Check if already completed # Check if already completed
if any(c["participant_id"] == participant_id for c in completions): if any(c["participant_id"] == participant_id for c in completions):
print(f"[COMMON_ENEMY] Participant {participant_id} already completed")
return 0, False, None return 0, False, None
# Add completion # Add completion
@@ -174,6 +247,7 @@ class EventService:
"completed_at": datetime.utcnow().isoformat(), "completed_at": datetime.utcnow().isoformat(),
"rank": rank, "rank": rank,
}) })
print(f"[COMMON_ENEMY] Added completion for user {user_id}, rank={rank}")
# Update event data - need to flag_modified for SQLAlchemy to detect JSON changes # Update event data - need to flag_modified for SQLAlchemy to detect JSON changes
event.data = {**data, "completions": completions} event.data = {**data, "completions": completions}
@@ -189,6 +263,7 @@ class EventService:
event.end_time = datetime.utcnow() event.end_time = datetime.utcnow()
event_closed = True event_closed = True
winners_list = completions[:3] # Top 3 winners_list = completions[:3] # Top 3
print(f"[COMMON_ENEMY] Event auto-closed! Winners: {winners_list}")
await db.commit() await db.commit()

View File

@@ -13,76 +13,125 @@ class GPTService:
async def generate_challenges( async def generate_challenges(
self, self,
game_title: str, games: list[dict]
game_genre: str | None = None ) -> dict[int, list[ChallengeGenerated]]:
) -> list[ChallengeGenerated]:
""" """
Generate challenges for a game using GPT. Generate challenges for multiple games in one API call.
Args: Args:
game_title: Name of the game games: List of dicts with keys: id, title, genre
game_genre: Optional genre of the game
Returns: Returns:
List of generated challenges Dict mapping game_id to list of generated challenges
""" """
genre_text = f" (жанр: {game_genre})" if game_genre else "" if not games:
return {}
prompt = f"""Для видеоигры "{game_title}"{genre_text} сгенерируй 6 челленджей для игрового марафона. games_text = "\n".join([
f"- {g['title']}" + (f" (жанр: {g['genre']})" if g.get('genre') else "")
for g in games
])
Требования: prompt = f"""Ты — эксперт по видеоиграм. Сгенерируй по 6 КОНКРЕТНЫХ челленджей для каждой из следующих игр:
- 2 лёгких челленджа (15-30 минут игры)
- 2 средних челленджа (1-2 часа игры)
- 2 сложных челленджа (3+ часов или высокая сложность)
Для каждого челленджа укажи: {games_text}
- title: короткое название на русском (до 50 символов)
- description: что нужно сделать на русском (1-2 предложения)
- type: один из [completion, no_death, speedrun, collection, achievement, challenge_run]
- difficulty: easy/medium/hard
- points: очки (easy: 30-50, medium: 60-100, hard: 120-200)
- estimated_time: примерное время в минутах
- proof_type: screenshot/video/steam (что лучше подойдёт для проверки)
- proof_hint: что должно быть на скриншоте/видео для подтверждения на русском
Ответь ТОЛЬКО валидным JSON объектом с ключом "challenges" содержащим массив челленджей. ВАЖНО:
Пример формата: - ВСЕ ТЕКСТЫ (title, description, proof_hint) ОБЯЗАТЕЛЬНО ПИШИ НА РУССКОМ ЯЗЫКЕ!
{{"challenges": [{{"title": "...", "description": "...", "type": "...", "difficulty": "...", "points": 50, "estimated_time": 30, "proof_type": "...", "proof_hint": "..."}}]}}""" - Используй интернет для поиска актуальной информации об играх
- Челленджи должны быть СПЕЦИФИЧНЫМИ для каждой игры!
- Используй РЕАЛЬНЫЕ названия локаций, боссов, персонажей, миссий, уровней из игры
- Основывайся на том, какие челленджи РЕАЛЬНО делают игроки в этой игре
- НЕ генерируй абстрактные челленджи типа "пройди уровень" или "убей 10 врагов"
Требования по сложности ДЛЯ КАЖДОЙ ИГРЫ:
- 2 лёгких (15-30 мин): простые задачи
- 2 средних (1-2 часа): требуют навыка
- 2 сложных (3-12 часов): серьёзный челлендж
Формат ответа — JSON с объектом где ключи это ТОЧНЫЕ названия игр, как они указаны в запросе:
{{
"Название игры 1": {{
"challenges": [
{{"title": "...", "description": "...", "type": "completion|no_death|speedrun|collection|achievement|challenge_run", "difficulty": "easy|medium|hard", "points": 50, "estimated_time": 30, "proof_type": "screenshot|video|steam", "proof_hint": "..."}}
]
}},
"Название игры 2": {{
"challenges": [...]
}}
}}
points: easy=20-40, medium=45-75, hard=90-150
Ответь ТОЛЬКО JSON. ОПИСАНИЕ И НАЗВАНИЕ ЧЕЛЛЕНДЖА ТОЛЬКО НА РУССКОМ ЯЗЫКЕ!"""
response = await self.client.chat.completions.create( response = await self.client.chat.completions.create(
model="gpt-4o-mini", model="gpt-5",
messages=[{"role": "user", "content": prompt}], messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"}, response_format={"type": "json_object"},
temperature=0.7,
max_tokens=2000,
) )
content = response.choices[0].message.content content = response.choices[0].message.content
data = json.loads(content) data = json.loads(content)
# Map game titles to IDs (case-insensitive, strip whitespace)
title_to_id = {g['title'].lower().strip(): g['id'] for g in games}
# Also keep original titles for logging
id_to_title = {g['id']: g['title'] for g in games}
print(f"[GPT] Requested games: {[g['title'] for g in games]}")
print(f"[GPT] Response keys: {list(data.keys())}")
result = {}
for game_title, game_data in data.items():
# Try exact match first, then case-insensitive
game_id = title_to_id.get(game_title.lower().strip())
if not game_id:
# Try partial match if exact match fails
for stored_title, gid in title_to_id.items():
if stored_title in game_title.lower() or game_title.lower() in stored_title:
game_id = gid
break
if not game_id:
print(f"[GPT] Could not match game: '{game_title}'")
continue
challenges = [] challenges = []
for ch in data.get("challenges", []): for ch in game_data.get("challenges", []):
# Validate and normalize type challenges.append(self._parse_challenge(ch))
result[game_id] = challenges
print(f"[GPT] Generated {len(challenges)} challenges for '{id_to_title.get(game_id)}'")
return result
def _parse_challenge(self, ch: dict) -> ChallengeGenerated:
"""Parse and validate a single challenge from GPT response"""
ch_type = ch.get("type", "completion") ch_type = ch.get("type", "completion")
if ch_type not in ["completion", "no_death", "speedrun", "collection", "achievement", "challenge_run"]: if ch_type not in ["completion", "no_death", "speedrun", "collection", "achievement", "challenge_run"]:
ch_type = "completion" ch_type = "completion"
# Validate difficulty
difficulty = ch.get("difficulty", "medium") difficulty = ch.get("difficulty", "medium")
if difficulty not in ["easy", "medium", "hard"]: if difficulty not in ["easy", "medium", "hard"]:
difficulty = "medium" difficulty = "medium"
# Validate proof_type
proof_type = ch.get("proof_type", "screenshot") proof_type = ch.get("proof_type", "screenshot")
if proof_type not in ["screenshot", "video", "steam"]: if proof_type not in ["screenshot", "video", "steam"]:
proof_type = "screenshot" proof_type = "screenshot"
# Validate points points = ch.get("points", 30)
points = ch.get("points", 50)
if not isinstance(points, int) or points < 1: if not isinstance(points, int) or points < 1:
points = 50 points = 30
if difficulty == "easy":
points = max(20, min(40, points))
elif difficulty == "medium":
points = max(45, min(75, points))
elif difficulty == "hard":
points = max(90, min(150, points))
challenges.append(ChallengeGenerated( return ChallengeGenerated(
title=ch.get("title", "Unnamed Challenge")[:100], title=ch.get("title", "Unnamed Challenge")[:100],
description=ch.get("description", "Complete the challenge"), description=ch.get("description", "Complete the challenge"),
type=ch_type, type=ch_type,
@@ -91,6 +140,7 @@ class GPTService:
estimated_time=ch.get("estimated_time"), estimated_time=ch.get("estimated_time"),
proof_type=proof_type, proof_type=proof_type,
proof_hint=ch.get("proof_hint"), proof_hint=ch.get("proof_hint"),
)) )
return challenges
gpt_service = GPTService()

View File

@@ -13,19 +13,19 @@ class PointsService:
} }
MAX_STREAK_MULTIPLIER = 0.4 MAX_STREAK_MULTIPLIER = 0.4
DROP_PENALTIES = { # Drop penalty as percentage of challenge points
0: 0, # First drop is free DROP_PENALTY_PERCENTAGES = {
1: 10, 0: 0.5, # 1st drop: 50%
2: 25, 1: 0.75, # 2nd drop: 75%
} }
MAX_DROP_PENALTY = 50 MAX_DROP_PENALTY_PERCENTAGE = 1.0 # 3rd+ drop: 100%
# Event point multipliers # Event point multipliers
EVENT_MULTIPLIERS = { EVENT_MULTIPLIERS = {
EventType.GOLDEN_HOUR.value: 1.5, EventType.GOLDEN_HOUR.value: 1.5,
EventType.DOUBLE_RISK.value: 0.5, EventType.DOUBLE_RISK.value: 0.5,
EventType.JACKPOT.value: 3.0, EventType.JACKPOT.value: 3.0,
EventType.REMATCH.value: 0.5, # GAME_CHOICE uses 1.0 multiplier (default)
} }
def calculate_completion_points( def calculate_completion_points(
@@ -66,6 +66,7 @@ class PointsService:
def calculate_drop_penalty( def calculate_drop_penalty(
self, self,
consecutive_drops: int, consecutive_drops: int,
challenge_points: int,
event: Event | None = None event: Event | None = None
) -> int: ) -> int:
""" """
@@ -73,6 +74,7 @@ class PointsService:
Args: Args:
consecutive_drops: Number of drops since last completion consecutive_drops: Number of drops since last completion
challenge_points: Base points of the challenge being dropped
event: Active event (optional) event: Active event (optional)
Returns: Returns:
@@ -82,10 +84,11 @@ class PointsService:
if event and event.type == EventType.DOUBLE_RISK.value: if event and event.type == EventType.DOUBLE_RISK.value:
return 0 return 0
return self.DROP_PENALTIES.get( penalty_percentage = self.DROP_PENALTY_PERCENTAGES.get(
consecutive_drops, consecutive_drops,
self.MAX_DROP_PENALTY self.MAX_DROP_PENALTY_PERCENTAGE
) )
return int(challenge_points * penalty_percentage)
def apply_event_multiplier(self, base_points: int, event: Event | None) -> int: def apply_event_multiplier(self, base_points: int, event: Event | None) -> int:
"""Apply event multiplier to points""" """Apply event multiplier to points"""

View File

@@ -0,0 +1,269 @@
"""
Storage service for file uploads.
Supports both local filesystem and S3-compatible storage (FirstVDS).
"""
import logging
import uuid
from pathlib import Path
from typing import Literal
import boto3
from botocore.exceptions import ClientError, BotoCoreError
from botocore.config import Config
from app.core.config import settings
logger = logging.getLogger(__name__)
StorageFolder = Literal["avatars", "covers", "proofs"]
class StorageService:
"""Unified storage service with S3 and local filesystem support."""
def __init__(self):
self._s3_client = None
@property
def s3_client(self):
"""Lazy initialization of S3 client."""
if self._s3_client is None and settings.S3_ENABLED:
logger.info(f"Initializing S3 client: endpoint={settings.S3_ENDPOINT_URL}, bucket={settings.S3_BUCKET_NAME}")
try:
# Use signature_version=s3v4 for S3-compatible storage
self._s3_client = boto3.client(
"s3",
endpoint_url=settings.S3_ENDPOINT_URL,
aws_access_key_id=settings.S3_ACCESS_KEY_ID,
aws_secret_access_key=settings.S3_SECRET_ACCESS_KEY,
region_name=settings.S3_REGION or "us-east-1",
config=Config(signature_version="s3v4"),
)
logger.info("S3 client initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize S3 client: {e}")
self._s3_client = None
return self._s3_client
def generate_filename(self, prefix: str | int, original_filename: str | None) -> str:
"""Generate unique filename with prefix."""
ext = "jpg"
if original_filename and "." in original_filename:
ext = original_filename.rsplit(".", 1)[-1].lower()
return f"{prefix}_{uuid.uuid4().hex}.{ext}"
async def upload_file(
self,
content: bytes,
folder: StorageFolder,
filename: str,
content_type: str = "application/octet-stream",
) -> str:
"""
Upload file to storage.
Returns:
Path/key to the uploaded file (relative path for local, S3 key for S3)
"""
if settings.S3_ENABLED:
try:
return await self._upload_to_s3(content, folder, filename, content_type)
except Exception as e:
logger.error(f"S3 upload failed, falling back to local: {e}")
return await self._upload_to_local(content, folder, filename)
else:
return await self._upload_to_local(content, folder, filename)
async def _upload_to_s3(
self,
content: bytes,
folder: StorageFolder,
filename: str,
content_type: str,
) -> str:
"""Upload file to S3."""
key = f"{folder}/{filename}"
if not self.s3_client:
raise RuntimeError("S3 client not initialized")
try:
logger.info(f"Uploading to S3: bucket={settings.S3_BUCKET_NAME}, key={key}")
self.s3_client.put_object(
Bucket=settings.S3_BUCKET_NAME,
Key=key,
Body=content,
ContentType=content_type,
)
logger.info(f"Successfully uploaded to S3: {key}")
return key
except (ClientError, BotoCoreError) as e:
logger.error(f"S3 upload error: {e}")
raise RuntimeError(f"Failed to upload to S3: {e}")
async def _upload_to_local(
self,
content: bytes,
folder: StorageFolder,
filename: str,
) -> str:
"""Upload file to local filesystem."""
filepath = Path(settings.UPLOAD_DIR) / folder / filename
filepath.parent.mkdir(parents=True, exist_ok=True)
with open(filepath, "wb") as f:
f.write(content)
return str(filepath)
def get_url(self, path: str | None, folder: StorageFolder) -> str | None:
"""
Get public URL for a file.
Args:
path: File path/key (can be full path or just filename)
folder: Storage folder (avatars, covers, proofs)
Returns:
Public URL or None if path is None
"""
if not path:
return None
# Extract filename from path
filename = path.split("/")[-1]
if settings.S3_ENABLED:
# S3 URL
return f"{settings.S3_PUBLIC_URL}/{folder}/{filename}"
else:
# Local URL
return f"/uploads/{folder}/{filename}"
async def delete_file(self, path: str | None) -> bool:
"""
Delete file from storage.
Args:
path: File path/key
Returns:
True if deleted, False otherwise
"""
if not path:
return False
if settings.S3_ENABLED:
return await self._delete_from_s3(path)
else:
return await self._delete_from_local(path)
async def _delete_from_s3(self, key: str) -> bool:
"""Delete file from S3."""
try:
self.s3_client.delete_object(
Bucket=settings.S3_BUCKET_NAME,
Key=key,
)
return True
except ClientError:
return False
async def _delete_from_local(self, path: str) -> bool:
"""Delete file from local filesystem."""
try:
filepath = Path(path)
if filepath.exists():
filepath.unlink()
return True
return False
except Exception:
return False
async def get_file(
self,
path: str,
folder: StorageFolder,
) -> tuple[bytes, str] | None:
"""
Get file content from storage.
Args:
path: File path/key (can be full path or just filename)
folder: Storage folder
Returns:
Tuple of (content bytes, content_type) or None if not found
"""
if not path:
return None
# Extract filename from path
filename = path.split("/")[-1]
if settings.S3_ENABLED:
return await self._get_from_s3(folder, filename)
else:
return await self._get_from_local(folder, filename)
async def _get_from_s3(
self,
folder: StorageFolder,
filename: str,
) -> tuple[bytes, str] | None:
"""Get file from S3."""
key = f"{folder}/{filename}"
if not self.s3_client:
logger.error("S3 client not initialized")
return None
try:
response = self.s3_client.get_object(
Bucket=settings.S3_BUCKET_NAME,
Key=key,
)
content = response["Body"].read()
content_type = response.get("ContentType", "application/octet-stream")
return content, content_type
except ClientError as e:
logger.error(f"S3 get error for {key}: {e}")
return None
async def _get_from_local(
self,
folder: StorageFolder,
filename: str,
) -> tuple[bytes, str] | None:
"""Get file from local filesystem."""
filepath = Path(settings.UPLOAD_DIR) / folder / filename
if not filepath.exists():
return None
try:
with open(filepath, "rb") as f:
content = f.read()
# Determine content type from extension
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
content_types = {
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"png": "image/png",
"gif": "image/gif",
"webp": "image/webp",
"mp4": "video/mp4",
"webm": "video/webm",
"mov": "video/quicktime",
}
content_type = content_types.get(ext, "application/octet-stream")
return content, content_type
except Exception as e:
logger.error(f"Local get error for {filepath}: {e}")
return None
# Singleton instance
storage_service = StorageService()

View File

@@ -0,0 +1,281 @@
import logging
from typing import List
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models import User, Participant, Marathon
logger = logging.getLogger(__name__)
class TelegramNotifier:
"""Service for sending Telegram notifications."""
def __init__(self):
self.bot_token = settings.TELEGRAM_BOT_TOKEN
self.api_url = f"https://api.telegram.org/bot{self.bot_token}"
async def send_message(
self,
chat_id: int,
text: str,
parse_mode: str = "HTML",
reply_markup: dict | None = None
) -> bool:
"""Send a message to a Telegram chat."""
if not self.bot_token:
logger.warning("Telegram bot token not configured")
return False
try:
async with httpx.AsyncClient() as client:
payload = {
"chat_id": chat_id,
"text": text,
"parse_mode": parse_mode
}
if reply_markup:
payload["reply_markup"] = reply_markup
response = await client.post(
f"{self.api_url}/sendMessage",
json=payload,
timeout=10.0
)
if response.status_code == 200:
return True
else:
logger.error(f"Failed to send message: {response.text}")
return False
except Exception as e:
logger.error(f"Error sending Telegram message: {e}")
return False
async def notify_user(
self,
db: AsyncSession,
user_id: int,
message: str,
reply_markup: dict | None = None
) -> bool:
"""Send notification to a user by user_id."""
result = await db.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
if not user:
logger.warning(f"[Notify] User {user_id} not found")
return False
if not user.telegram_id:
logger.warning(f"[Notify] User {user_id} ({user.nickname}) has no telegram_id")
return False
logger.info(f"[Notify] Sending to user {user.nickname} (telegram_id={user.telegram_id})")
return await self.send_message(user.telegram_id, message, reply_markup=reply_markup)
async def notify_marathon_participants(
self,
db: AsyncSession,
marathon_id: int,
message: str,
exclude_user_id: int | None = None
) -> int:
"""Send notification to all marathon participants with linked Telegram."""
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 = result.scalars().all()
sent_count = 0
for user in users:
if exclude_user_id and user.id == exclude_user_id:
continue
if await self.send_message(user.telegram_id, message):
sent_count += 1
return sent_count
# Notification templates
async def notify_event_start(
self,
db: AsyncSession,
marathon_id: int,
event_type: str,
marathon_title: str
) -> int:
"""Notify participants about event start."""
event_messages = {
"golden_hour": f"🌟 <b>Начался Golden Hour</b> в «{marathon_title}»!\n\nВсе очки x1.5 в течение часа!",
"jackpot": f"🎰 <b>JACKPOT</b> в «{marathon_title}»!\n\nОчки x3 за следующий сложный челлендж!",
"double_risk": f"⚡ <b>Double Risk</b> в «{marathon_title}»!\n\nПоловина очков, но дропы бесплатны!",
"common_enemy": f"👥 <b>Common Enemy</b> в «{marathon_title}»!\n\nВсе получают одинаковый челлендж. Первые 3 — бонус!",
"swap": f"🔄 <b>Swap</b> в «{marathon_title}»!\n\nМожно поменяться заданием с другим участником!",
"game_choice": f"🎲 <b>Выбор игры</b> в «{marathon_title}»!\n\nВыбери игру и один из 3 челленджей!"
}
message = event_messages.get(
event_type,
f"📌 Новое событие в «{marathon_title}»!"
)
return await self.notify_marathon_participants(db, marathon_id, message)
async def notify_event_end(
self,
db: AsyncSession,
marathon_id: int,
event_type: str,
marathon_title: str
) -> int:
"""Notify participants about event end."""
event_names = {
"golden_hour": "Golden Hour",
"jackpot": "Jackpot",
"double_risk": "Double Risk",
"common_enemy": "Common Enemy",
"swap": "Swap",
"game_choice": "Выбор игры"
}
event_name = event_names.get(event_type, "Событие")
message = f"⏰ <b>{event_name}</b> в «{marathon_title}» завершён"
return await self.notify_marathon_participants(db, marathon_id, message)
async def notify_marathon_start(
self,
db: AsyncSession,
marathon_id: int,
marathon_title: str
) -> int:
"""Notify participants about marathon start."""
message = (
f"🚀 <b>Марафон «{marathon_title}» начался!</b>\n\n"
f"Время крутить колесо и получить первое задание!"
)
return await self.notify_marathon_participants(db, marathon_id, message)
async def notify_marathon_finish(
self,
db: AsyncSession,
marathon_id: int,
marathon_title: str
) -> int:
"""Notify participants about marathon finish."""
message = (
f"🏆 <b>Марафон «{marathon_title}» завершён!</b>\n\n"
f"Зайди на сайт, чтобы увидеть итоговую таблицу!"
)
return await self.notify_marathon_participants(db, marathon_id, message)
async def notify_dispute_raised(
self,
db: AsyncSession,
user_id: int,
marathon_title: str,
challenge_title: str,
assignment_id: int
) -> bool:
"""Notify user about dispute raised on their assignment."""
logger.info(f"[Dispute] Sending notification to user_id={user_id} for assignment_id={assignment_id}")
dispute_url = f"{settings.FRONTEND_URL}/assignments/{assignment_id}"
logger.info(f"[Dispute] URL: {dispute_url}")
# Telegram requires HTTPS for inline keyboard URLs
use_inline_button = dispute_url.startswith("https://")
if use_inline_button:
message = (
f"⚠️ <b>На твоё задание подан спор</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Задание: {challenge_title}"
)
reply_markup = {
"inline_keyboard": [[
{"text": "Открыть спор", "url": dispute_url}
]]
}
else:
message = (
f"⚠️ <b>На твоё задание подан спор</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Задание: {challenge_title}\n\n"
f"🔗 {dispute_url}"
)
reply_markup = None
result = await self.notify_user(db, user_id, message, reply_markup=reply_markup)
logger.info(f"[Dispute] Notification result: {result}")
return result
async def notify_dispute_resolved(
self,
db: AsyncSession,
user_id: int,
marathon_title: str,
challenge_title: str,
is_valid: bool
) -> bool:
"""Notify user about dispute resolution."""
if is_valid:
message = (
f"❌ <b>Спор признан обоснованным</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Задание: {challenge_title}\n\n"
f"Задание возвращено. Выполни его заново."
)
else:
message = (
f"✅ <b>Спор отклонён</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Задание: {challenge_title}\n\n"
f"Твоё выполнение засчитано!"
)
return await self.notify_user(db, user_id, message)
async def notify_game_approved(
self,
db: AsyncSession,
user_id: int,
marathon_title: str,
game_title: str
) -> bool:
"""Notify user that their proposed game was approved."""
message = (
f"✅ <b>Твоя игра одобрена!</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Игра: {game_title}\n\n"
f"Теперь она доступна для всех участников."
)
return await self.notify_user(db, user_id, message)
async def notify_game_rejected(
self,
db: AsyncSession,
user_id: int,
marathon_title: str,
game_title: str
) -> bool:
"""Notify user that their proposed game was rejected."""
message = (
f"❌ <b>Твоя игра отклонена</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Игра: {game_title}\n\n"
f"Ты можешь предложить другую игру."
)
return await self.notify_user(db, user_id, message)
# Global instance
telegram_notifier = TelegramNotifier()

View File

@@ -19,7 +19,7 @@ pydantic-settings==2.1.0
email-validator==2.1.0 email-validator==2.1.0
# OpenAI # OpenAI
openai==1.12.0 openai==2.12.0
# Telegram notifications # Telegram notifications
httpx==0.26.0 httpx==0.26.0
@@ -28,5 +28,11 @@ httpx==0.26.0
aiofiles==23.2.1 aiofiles==23.2.1
python-magic==0.4.27 python-magic==0.4.27
# S3 Storage
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())

10
bot/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "main.py"]

15
bot/config.py Normal file
View File

@@ -0,0 +1,15 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
TELEGRAM_BOT_TOKEN: str
API_URL: str = "http://backend:8000"
BOT_USERNAME: str = "" # Will be set dynamically on startup
BOT_API_SECRET: str = "" # Secret for backend API communication
class Config:
env_file = ".env"
extra = "ignore"
settings = Settings()

1
bot/handlers/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Bot handlers

60
bot/handlers/link.py Normal file
View File

@@ -0,0 +1,60 @@
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message
from keyboards.main_menu import get_main_menu
from services.api_client import api_client
router = Router()
@router.message(Command("unlink"))
async def cmd_unlink(message: Message):
"""Handle /unlink command to disconnect Telegram account."""
user = await api_client.get_user_by_telegram_id(message.from_user.id)
if not user:
await message.answer(
"Твой аккаунт не привязан к Game Marathon.\n"
"Привяжи его через настройки профиля на сайте.",
reply_markup=get_main_menu()
)
return
result = await api_client.unlink_telegram(message.from_user.id)
if result.get("success"):
await message.answer(
"<b>Аккаунт отвязан</b>\n\n"
"Ты больше не будешь получать уведомления.\n"
"Чтобы привязать аккаунт снова, используй кнопку в настройках профиля на сайте.",
reply_markup=get_main_menu()
)
else:
await message.answer(
"Произошла ошибка при отвязке аккаунта.\n"
"Попробуй позже или обратись к администратору.",
reply_markup=get_main_menu()
)
@router.message(Command("status"))
async def cmd_status(message: Message):
"""Check account link status."""
user = await api_client.get_user_by_telegram_id(message.from_user.id)
if user:
await message.answer(
f"<b>Статус аккаунта</b>\n\n"
f"✅ Аккаунт привязан\n"
f"👤 Никнейм: <b>{user.get('nickname', 'N/A')}</b>\n"
f"🆔 ID: {user.get('id', 'N/A')}",
reply_markup=get_main_menu()
)
else:
await message.answer(
"<b>Статус аккаунта</b>\n\n"
"❌ Аккаунт не привязан\n\n"
"Привяжи его через настройки профиля на сайте.",
reply_markup=get_main_menu()
)

211
bot/handlers/marathons.py Normal file
View File

@@ -0,0 +1,211 @@
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery
from keyboards.main_menu import get_main_menu
from keyboards.inline import get_marathons_keyboard, get_marathon_details_keyboard
from services.api_client import api_client
router = Router()
@router.message(Command("marathons"))
@router.message(F.text == "📊 Мои марафоны")
async def cmd_marathons(message: Message):
"""Show user's marathons."""
user = await api_client.get_user_by_telegram_id(message.from_user.id)
if not user:
await message.answer(
"Сначала привяжи аккаунт через настройки профиля на сайте.",
reply_markup=get_main_menu()
)
return
marathons = await api_client.get_user_marathons(message.from_user.id)
if not marathons:
await message.answer(
"<b>Мои марафоны</b>\n\n"
"У тебя пока нет активных марафонов.\n"
"Присоединись к марафону на сайте!",
reply_markup=get_main_menu()
)
return
text = "<b>📊 Мои марафоны</b>\n\n"
for m in marathons:
status_emoji = {
"preparing": "",
"active": "🎮",
"finished": "🏁"
}.get(m.get("status"), "")
text += f"{status_emoji} <b>{m.get('title')}</b>\n"
text += f" Очки: {m.get('total_points', 0)} | "
text += f"Место: #{m.get('position', '?')}\n\n"
await message.answer(
text,
reply_markup=get_marathons_keyboard(marathons)
)
@router.callback_query(F.data.startswith("marathon:"))
async def marathon_details(callback: CallbackQuery):
"""Show marathon details."""
marathon_id = int(callback.data.split(":")[1])
details = await api_client.get_marathon_details(
marathon_id=marathon_id,
telegram_id=callback.from_user.id
)
if not details:
await callback.answer("Не удалось загрузить данные марафона", show_alert=True)
return
marathon = details.get("marathon", {})
participant = details.get("participant", {})
active_events = details.get("active_events", [])
current_assignment = details.get("current_assignment")
status_text = {
"preparing": "⏳ Подготовка",
"active": "🎮 Активен",
"finished": "🏁 Завершён"
}.get(marathon.get("status"), "")
text = f"<b>{marathon.get('title')}</b>\n"
text += f"Статус: {status_text}\n\n"
text += f"<b>📈 Твоя статистика:</b>\n"
text += f"• Очки: <b>{participant.get('total_points', 0)}</b>\n"
text += f"• Место: <b>#{details.get('position', '?')}</b>\n"
text += f"• Стрик: <b>{participant.get('current_streak', 0)}</b> 🔥\n"
text += f"• Дропов: <b>{participant.get('drop_count', 0)}</b>\n\n"
if active_events:
text += "<b>⚡ Активные события:</b>\n"
for event in active_events:
event_emoji = {
"golden_hour": "🌟",
"jackpot": "🎰",
"double_risk": "",
"common_enemy": "👥",
"swap": "🔄",
"game_choice": "🎲"
}.get(event.get("type"), "📌")
text += f"{event_emoji} {event.get('type', '').replace('_', ' ').title()}\n"
text += "\n"
if current_assignment:
challenge = current_assignment.get("challenge", {})
game = challenge.get("game", {})
text += f"<b>🎯 Текущее задание:</b>\n"
text += f"Игра: {game.get('title', 'N/A')}\n"
text += f"Задание: {challenge.get('title', 'N/A')}\n"
text += f"Сложность: {challenge.get('difficulty', 'N/A')}\n"
text += f"Очки: {challenge.get('points', 0)}\n"
await callback.message.edit_text(
text,
reply_markup=get_marathon_details_keyboard(marathon_id)
)
await callback.answer()
@router.callback_query(F.data == "back_to_marathons")
async def back_to_marathons(callback: CallbackQuery):
"""Go back to marathons list."""
marathons = await api_client.get_user_marathons(callback.from_user.id)
if not marathons:
await callback.message.edit_text(
"<b>Мои марафоны</b>\n\n"
"У тебя пока нет активных марафонов."
)
await callback.answer()
return
text = "<b>📊 Мои марафоны</b>\n\n"
for m in marathons:
status_emoji = {
"preparing": "",
"active": "🎮",
"finished": "🏁"
}.get(m.get("status"), "")
text += f"{status_emoji} <b>{m.get('title')}</b>\n"
text += f" Очки: {m.get('total_points', 0)} | "
text += f"Место: #{m.get('position', '?')}\n\n"
await callback.message.edit_text(
text,
reply_markup=get_marathons_keyboard(marathons)
)
await callback.answer()
@router.message(Command("stats"))
@router.message(F.text == "📈 Статистика")
async def cmd_stats(message: Message):
"""Show user's overall statistics."""
user = await api_client.get_user_by_telegram_id(message.from_user.id)
if not user:
await message.answer(
"Сначала привяжи аккаунт через настройки профиля на сайте.",
reply_markup=get_main_menu()
)
return
stats = await api_client.get_user_stats(message.from_user.id)
if not stats:
await message.answer(
"<b>📈 Статистика</b>\n\n"
"Пока нет данных для отображения.\n"
"Начни участвовать в марафонах!",
reply_markup=get_main_menu()
)
return
text = f"<b>📈 Общая статистика</b>\n\n"
text += f"👤 <b>{user.get('nickname', 'Игрок')}</b>\n\n"
text += f"🏆 Марафонов завершено: <b>{stats.get('marathons_completed', 0)}</b>\n"
text += f"🎮 Марафонов активно: <b>{stats.get('marathons_active', 0)}</b>\n"
text += f"✅ Заданий выполнено: <b>{stats.get('challenges_completed', 0)}</b>\n"
text += f"💰 Всего очков: <b>{stats.get('total_points', 0)}</b>\n"
text += f"🔥 Лучший стрик: <b>{stats.get('best_streak', 0)}</b>\n"
await message.answer(text, reply_markup=get_main_menu())
@router.message(Command("settings"))
@router.message(F.text == "⚙️ Настройки")
async def cmd_settings(message: Message):
"""Show notification settings."""
user = await api_client.get_user_by_telegram_id(message.from_user.id)
if not user:
await message.answer(
"Сначала привяжи аккаунт через настройки профиля на сайте.",
reply_markup=get_main_menu()
)
return
await message.answer(
"<b>⚙️ Настройки</b>\n\n"
"Управление уведомлениями будет доступно в следующем обновлении.\n\n"
"Сейчас ты получаешь все уведомления:\n"
"• 🌟 События (Golden Hour, Jackpot и др.)\n"
"• 🚀 Старт/финиш марафонов\n"
"• ⚠️ Споры по заданиям\n\n"
"Команды:\n"
"/unlink - Отвязать аккаунт\n"
"/status - Проверить привязку",
reply_markup=get_main_menu()
)

146
bot/handlers/start.py Normal file
View File

@@ -0,0 +1,146 @@
import logging
from aiogram import Router, F, Bot
from aiogram.filters import CommandStart, Command, CommandObject
from aiogram.types import Message
from config import settings
from keyboards.main_menu import get_main_menu
from services.api_client import api_client
logger = logging.getLogger(__name__)
router = Router()
async def get_user_avatar_url(bot: Bot, user_id: int) -> str | None:
"""Get user's Telegram profile photo URL."""
try:
photos = await bot.get_user_profile_photos(user_id, limit=1)
if photos.total_count > 0 and photos.photos:
# Get the largest photo (last in the list)
photo = photos.photos[0][-1]
file = await bot.get_file(photo.file_id)
if file.file_path:
return f"https://api.telegram.org/file/bot{settings.TELEGRAM_BOT_TOKEN}/{file.file_path}"
except Exception as e:
logger.warning(f"[START] Could not get user avatar: {e}")
return None
@router.message(CommandStart())
async def cmd_start(message: Message, command: CommandObject):
"""Handle /start command with or without deep link."""
logger.info(f"[START] ==================== START COMMAND ====================")
logger.info(f"[START] Telegram user: id={message.from_user.id}, username=@{message.from_user.username}")
logger.info(f"[START] Full message text: '{message.text}'")
logger.info(f"[START] Deep link args (command.args): '{command.args}'")
# Check if there's a deep link token (for account linking)
token = command.args
if token:
logger.info(f"[START] -------- TOKEN RECEIVED --------")
logger.info(f"[START] Token: {token}")
logger.info(f"[START] Token length: {len(token)} chars")
# Get user's avatar
avatar_url = await get_user_avatar_url(message.bot, message.from_user.id)
logger.info(f"[START] User avatar URL: {avatar_url}")
logger.info(f"[START] -------- CALLING API --------")
logger.info(f"[START] Sending to /telegram/confirm-link:")
logger.info(f"[START] - token: {token}")
logger.info(f"[START] - telegram_id: {message.from_user.id}")
logger.info(f"[START] - telegram_username: {message.from_user.username}")
logger.info(f"[START] - telegram_first_name: {message.from_user.first_name}")
logger.info(f"[START] - telegram_last_name: {message.from_user.last_name}")
logger.info(f"[START] - telegram_avatar_url: {avatar_url}")
result = await api_client.confirm_telegram_link(
token=token,
telegram_id=message.from_user.id,
telegram_username=message.from_user.username,
telegram_first_name=message.from_user.first_name,
telegram_last_name=message.from_user.last_name,
telegram_avatar_url=avatar_url
)
logger.info(f"[START] -------- API RESPONSE --------")
logger.info(f"[START] Response: {result}")
logger.info(f"[START] Success: {result.get('success')}")
if result.get("success"):
user_nickname = result.get("nickname", "пользователь")
logger.info(f"[START] ✅ LINK SUCCESS! User '{user_nickname}' linked to telegram_id={message.from_user.id}")
await message.answer(
f"<b>Аккаунт успешно привязан!</b>\n\n"
f"Привет, <b>{user_nickname}</b>!\n\n"
f"Теперь ты будешь получать уведомления о:\n"
f"• Начале и окончании событий (Golden Hour, Jackpot и др.)\n"
f"• Старте и завершении марафонов\n"
f"• Спорах по твоим заданиям\n\n"
f"Используй меню ниже для навигации:",
reply_markup=get_main_menu()
)
return
else:
error = result.get("error", "Неизвестная ошибка")
logger.error(f"[START] ❌ LINK FAILED!")
logger.error(f"[START] Error: {error}")
logger.error(f"[START] Token was: {token}")
await message.answer(
f"<b>Ошибка привязки аккаунта</b>\n\n"
f"{error}\n\n"
f"Попробуй получить новую ссылку на сайте.",
reply_markup=get_main_menu()
)
return
# No token - regular start
logger.info(f"[START] No token, checking if user is already linked...")
user = await api_client.get_user_by_telegram_id(message.from_user.id)
logger.info(f"[START] API response: {user}")
if user:
await message.answer(
f"<b>С возвращением, {user.get('nickname', 'игрок')}!</b>\n\n"
f"Твой аккаунт привязан. Используй меню для навигации:",
reply_markup=get_main_menu()
)
else:
await message.answer(
"<b>Добро пожаловать в Game Marathon Bot!</b>\n\n"
"Этот бот поможет тебе следить за марафонами и "
"получать уведомления о важных событиях.\n\n"
"<b>Для начала работы:</b>\n"
"1. Зайди на сайт в настройки профиля\n"
"2. Нажми кнопку «Привязать Telegram»\n"
"3. Перейди по полученной ссылке\n\n"
"После привязки ты сможешь:\n"
"• Смотреть свои марафоны\n"
"• Получать уведомления о событиях\n"
"• Следить за статистикой",
reply_markup=get_main_menu()
)
@router.message(Command("help"))
@router.message(F.text == "❓ Помощь")
async def cmd_help(message: Message):
"""Handle /help command."""
await message.answer(
"<b>Справка по командам:</b>\n\n"
"/start - Начать работу с ботом\n"
"/marathons - Мои марафоны\n"
"/stats - Моя статистика\n"
"/settings - Настройки уведомлений\n"
"/help - Эта справка\n\n"
"<b>Уведомления:</b>\n"
"Бот присылает уведомления о:\n"
"• 🌟 Golden Hour - очки x1.5\n"
"• 🎰 Jackpot - очки x3\n"
"• ⚡ Double Risk - половина очков, дропы бесплатны\n"
"• 👥 Common Enemy - общий челлендж\n"
"• 🚀 Старт/финиш марафонов\n"
"• ⚠️ Споры по заданиям",
reply_markup=get_main_menu()
)

View File

@@ -0,0 +1 @@
# Bot keyboards

42
bot/keyboards/inline.py Normal file
View File

@@ -0,0 +1,42 @@
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
def get_marathons_keyboard(marathons: list) -> InlineKeyboardMarkup:
"""Create keyboard with marathon buttons."""
buttons = []
for marathon in marathons:
status_emoji = {
"preparing": "",
"active": "🎮",
"finished": "🏁"
}.get(marathon.get("status"), "")
buttons.append([
InlineKeyboardButton(
text=f"{status_emoji} {marathon.get('title', 'Marathon')}",
callback_data=f"marathon:{marathon.get('id')}"
)
])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_marathon_details_keyboard(marathon_id: int) -> InlineKeyboardMarkup:
"""Create keyboard for marathon details view."""
buttons = [
[
InlineKeyboardButton(
text="🔄 Обновить",
callback_data=f"marathon:{marathon_id}"
)
],
[
InlineKeyboardButton(
text="◀️ Назад к списку",
callback_data="back_to_marathons"
)
]
]
return InlineKeyboardMarkup(inline_keyboard=buttons)

View File

@@ -0,0 +1,21 @@
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton
def get_main_menu() -> ReplyKeyboardMarkup:
"""Create main menu keyboard."""
keyboard = [
[
KeyboardButton(text="📊 Мои марафоны"),
KeyboardButton(text="📈 Статистика")
],
[
KeyboardButton(text="⚙️ Настройки"),
KeyboardButton(text="❓ Помощь")
]
]
return ReplyKeyboardMarkup(
keyboard=keyboard,
resize_keyboard=True,
input_field_placeholder="Выбери действие..."
)

100
bot/main.py Normal file
View File

@@ -0,0 +1,100 @@
import asyncio
import logging
import sys
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiohttp import web
from config import settings
from handlers import start, marathons, link
from middlewares.logging import LoggingMiddleware
# Configure logging to stdout with DEBUG level
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger(__name__)
# Set aiogram logging level
logging.getLogger("aiogram").setLevel(logging.INFO)
# Health check state
bot_running = False
async def health_handler(request):
"""Health check endpoint"""
if bot_running:
return web.json_response({"status": "ok", "service": "telegram-bot"})
return web.json_response({"status": "starting"}, status=503)
async def start_health_server():
"""Start health check HTTP server"""
app = web.Application()
app.router.add_get("/health", health_handler)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", 8080)
await site.start()
logger.info("Health check server started on port 8080")
return runner
async def main():
global bot_running
logger.info("="*50)
logger.info("Starting Game Marathon Bot...")
logger.info(f"API_URL: {settings.API_URL}")
logger.info(f"BOT_TOKEN: {settings.TELEGRAM_BOT_TOKEN[:20]}...")
logger.info("="*50)
# Start health check server
health_runner = await start_health_server()
bot = Bot(
token=settings.TELEGRAM_BOT_TOKEN,
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
)
# Get bot username for deep links
bot_info = await bot.get_me()
settings.BOT_USERNAME = bot_info.username
logger.info(f"Bot info: @{settings.BOT_USERNAME} (id={bot_info.id})")
dp = Dispatcher()
# Register middleware
dp.message.middleware(LoggingMiddleware())
logger.info("Logging middleware registered")
# Register routers
logger.info("Registering routers...")
dp.include_router(start.router)
dp.include_router(link.router)
dp.include_router(marathons.router)
logger.info("Routers registered: start, link, marathons")
# Mark bot as running
bot_running = True
# Start polling
logger.info("Deleting webhook and starting polling...")
await bot.delete_webhook(drop_pending_updates=True)
logger.info("Polling started! Waiting for messages...")
try:
await dp.start_polling(bot)
finally:
bot_running = False
await health_runner.cleanup()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1 @@
# Bot middlewares

View File

@@ -0,0 +1,28 @@
import logging
from typing import Any, Awaitable, Callable, Dict
from aiogram import BaseMiddleware
from aiogram.types import Message, Update
logger = logging.getLogger(__name__)
class LoggingMiddleware(BaseMiddleware):
async def __call__(
self,
handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]],
event: Message,
data: Dict[str, Any]
) -> Any:
logger.info("="*60)
logger.info(f"[MIDDLEWARE] Incoming message from user {event.from_user.id}")
logger.info(f"[MIDDLEWARE] Username: @{event.from_user.username}")
logger.info(f"[MIDDLEWARE] Text: {event.text}")
logger.info(f"[MIDDLEWARE] Message ID: {event.message_id}")
logger.info(f"[MIDDLEWARE] Chat ID: {event.chat.id}")
logger.info("="*60)
result = await handler(event, data)
logger.info(f"[MIDDLEWARE] Handler completed for message {event.message_id}")
return result

5
bot/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
aiogram==3.23.0
aiohttp==3.10.5
pydantic==2.9.2
pydantic-settings==2.5.2
python-dotenv==1.0.1

1
bot/services/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Bot services

134
bot/services/api_client.py Normal file
View File

@@ -0,0 +1,134 @@
import logging
from typing import Any
import aiohttp
from config import settings
logger = logging.getLogger(__name__)
class APIClient:
"""HTTP client for backend API communication."""
def __init__(self):
self.base_url = settings.API_URL
self._session: aiohttp.ClientSession | None = None
logger.info(f"[APIClient] Initialized with base_url: {self.base_url}")
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
logger.info("[APIClient] Creating new aiohttp session")
self._session = aiohttp.ClientSession()
return self._session
async def _request(
self,
method: str,
endpoint: str,
**kwargs
) -> dict[str, Any] | None:
"""Make HTTP request to backend API."""
session = await self._get_session()
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}")
if 'json' in kwargs:
logger.info(f"[APIClient] Request body: {kwargs['json']}")
if 'params' in kwargs:
logger.info(f"[APIClient] Request params: {kwargs['params']}")
try:
async with session.request(method, url, headers=headers, **kwargs) as response:
logger.info(f"[APIClient] Response status: {response.status}")
response_text = await response.text()
logger.info(f"[APIClient] Response body: {response_text[:500]}")
if response.status == 200:
import json
return json.loads(response_text)
elif response.status == 404:
logger.warning(f"[APIClient] 404 Not Found")
return None
else:
logger.error(f"[APIClient] API error {response.status}: {response_text}")
return {"error": response_text}
except aiohttp.ClientError as e:
logger.error(f"[APIClient] Request failed: {e}")
return {"error": str(e)}
except Exception as e:
logger.error(f"[APIClient] Unexpected error: {e}")
return {"error": str(e)}
async def confirm_telegram_link(
self,
token: str,
telegram_id: int,
telegram_username: str | None,
telegram_first_name: str | None = None,
telegram_last_name: str | None = None,
telegram_avatar_url: str | None = None
) -> dict[str, Any]:
"""Confirm Telegram account linking."""
result = await self._request(
"POST",
"/telegram/confirm-link",
json={
"token": token,
"telegram_id": telegram_id,
"telegram_username": telegram_username,
"telegram_first_name": telegram_first_name,
"telegram_last_name": telegram_last_name,
"telegram_avatar_url": telegram_avatar_url
}
)
return result or {"error": "Не удалось связаться с сервером"}
async def get_user_by_telegram_id(self, telegram_id: int) -> dict[str, Any] | None:
"""Get user by Telegram ID."""
return await self._request("GET", f"/telegram/user/{telegram_id}")
async def unlink_telegram(self, telegram_id: int) -> dict[str, Any]:
"""Unlink Telegram account."""
result = await self._request(
"POST",
f"/telegram/unlink/{telegram_id}"
)
return result or {"error": "Не удалось связаться с сервером"}
async def get_user_marathons(self, telegram_id: int) -> list[dict[str, Any]]:
"""Get user's marathons."""
result = await self._request("GET", f"/telegram/marathons/{telegram_id}")
if isinstance(result, list):
return result
return result.get("marathons", []) if result else []
async def get_marathon_details(
self,
marathon_id: int,
telegram_id: int
) -> dict[str, Any] | None:
"""Get marathon details for user."""
return await self._request(
"GET",
f"/telegram/marathon/{marathon_id}",
params={"telegram_id": telegram_id}
)
async def get_user_stats(self, telegram_id: int) -> dict[str, Any] | None:
"""Get user's overall statistics."""
return await self._request("GET", f"/telegram/stats/{telegram_id}")
async def close(self):
"""Close the HTTP session."""
if self._session and not self._session.closed:
await self._session.close()
# Global API client instance
api_client = APIClient()

View File

@@ -27,7 +27,17 @@ 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}
BOT_API_SECRET: ${BOT_API_SECRET:-}
DEBUG: ${DEBUG:-false} DEBUG: ${DEBUG:-false}
# S3 Storage
S3_ENABLED: ${S3_ENABLED:-false}
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_PUBLIC_URL: ${S3_PUBLIC_URL:-}
volumes: volumes:
- ./backend/uploads:/app/uploads - ./backend/uploads:/app/uploads
- ./backend/app:/app/app - ./backend/app:/app/app
@@ -64,5 +74,68 @@ services:
- backend - backend
restart: unless-stopped restart: unless-stopped
bot:
build:
context: ./bot
dockerfile: Dockerfile
container_name: marathon-bot
environment:
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
- API_URL=http://backend:8000
- BOT_API_SECRET=${BOT_API_SECRET:-}
depends_on:
- backend
restart: unless-stopped
status:
build:
context: ./status-service
dockerfile: Dockerfile
container_name: marathon-status
environment:
BACKEND_URL: http://backend:8000
FRONTEND_URL: http://frontend:80
BOT_URL: http://bot:8080
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,5 +1,6 @@
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'
// Layout // Layout
import { Layout } from '@/components/layout/Layout' import { Layout } from '@/components/layout/Layout'
@@ -15,6 +16,12 @@ import { LobbyPage } from '@/pages/LobbyPage'
import { PlayPage } from '@/pages/PlayPage' import { PlayPage } from '@/pages/PlayPage'
import { LeaderboardPage } from '@/pages/LeaderboardPage' import { LeaderboardPage } from '@/pages/LeaderboardPage'
import { InvitePage } from '@/pages/InvitePage' import { InvitePage } from '@/pages/InvitePage'
import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage'
import { ProfilePage } from '@/pages/ProfilePage'
import { UserProfilePage } from '@/pages/UserProfilePage'
import { NotFoundPage } from '@/pages/NotFoundPage'
import { TeapotPage } from '@/pages/TeapotPage'
import { ServerErrorPage } from '@/pages/ServerErrorPage'
// Protected route wrapper // Protected route wrapper
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -40,6 +47,9 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
function App() { function App() {
return ( return (
<>
<ToastContainer />
<ConfirmModal />
<Routes> <Routes>
<Route path="/" element={<Layout />}> <Route path="/" element={<Layout />}>
<Route index element={<HomePage />} /> <Route index element={<HomePage />} />
@@ -118,8 +128,42 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="assignments/:id"
element={
<ProtectedRoute>
<AssignmentDetailPage />
</ProtectedRoute>
}
/>
{/* Profile routes */}
<Route
path="profile"
element={
<ProtectedRoute>
<ProfilePage />
</ProtectedRoute>
}
/>
<Route path="users/:id" element={<UserProfilePage />} />
{/* Easter egg - 418 I'm a teapot */}
<Route path="418" element={<TeapotPage />} />
<Route path="teapot" element={<TeapotPage />} />
<Route path="tea" element={<TeapotPage />} />
{/* Server error page */}
<Route path="500" element={<ServerErrorPage />} />
<Route path="error" element={<ServerErrorPage />} />
{/* 404 - must be last */}
<Route path="*" element={<NotFoundPage />} />
</Route> </Route>
</Routes> </Routes>
</>
) )
} }

View File

@@ -0,0 +1,47 @@
import client from './client'
import type { AssignmentDetail, Dispute, DisputeComment, ReturnedAssignment } from '@/types'
export const assignmentsApi = {
// Get detailed assignment info with proofs and dispute
getDetail: async (assignmentId: number): Promise<AssignmentDetail> => {
const response = await client.get<AssignmentDetail>(`/assignments/${assignmentId}`)
return response.data
},
// Create a dispute against an assignment
createDispute: async (assignmentId: number, reason: string): Promise<Dispute> => {
const response = await client.post<Dispute>(`/assignments/${assignmentId}/dispute`, { reason })
return response.data
},
// Add a comment to a dispute
addComment: async (disputeId: number, text: string): Promise<DisputeComment> => {
const response = await client.post<DisputeComment>(`/disputes/${disputeId}/comments`, { text })
return response.data
},
// Vote on a dispute (true = valid/proof is OK, false = invalid/proof is not OK)
vote: async (disputeId: number, vote: boolean): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>(`/disputes/${disputeId}/vote`, { vote })
return response.data
},
// Get current user's returned assignments
getReturnedAssignments: async (marathonId: number): Promise<ReturnedAssignment[]> => {
const response = await client.get<ReturnedAssignment[]>(`/marathons/${marathonId}/returned-assignments`)
return response.data
},
// Get proof media as blob URL (supports both images and videos)
getProofMediaUrl: async (assignmentId: number): Promise<{ url: string; type: 'image' | 'video' }> => {
const response = await client.get(`/assignments/${assignmentId}/proof-media`, {
responseType: 'blob',
})
const contentType = response.headers['content-type'] || ''
const isVideo = contentType.startsWith('video/')
return {
url: URL.createObjectURL(response.data),
type: isVideo ? 'video' : 'image',
}
},
}

View File

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

View File

@@ -1,5 +1,5 @@
import client from './client' import client from './client'
import type { ActiveEvent, MarathonEvent, EventCreate, DroppedAssignment, SwapCandidate, SwapRequestItem, MySwapRequests, CommonEnemyLeaderboardEntry } from '@/types' import type { ActiveEvent, MarathonEvent, EventCreate, SwapCandidate, SwapRequestItem, MySwapRequests, CommonEnemyLeaderboardEntry, EventAssignment, CompleteResult, GameChoiceChallenges } from '@/types'
export const eventsApi = { export const eventsApi = {
getActive: async (marathonId: number): Promise<ActiveEvent> => { getActive: async (marathonId: number): Promise<ActiveEvent> => {
@@ -46,12 +46,18 @@ export const eventsApi = {
await client.delete(`/marathons/${marathonId}/swap-requests/${requestId}`) await client.delete(`/marathons/${marathonId}/swap-requests/${requestId}`)
}, },
rematch: async (marathonId: number, assignmentId: number): Promise<void> => { // Game Choice event
await client.post(`/marathons/${marathonId}/rematch/${assignmentId}`) getGameChoiceChallenges: async (marathonId: number, gameId: number): Promise<GameChoiceChallenges> => {
const response = await client.get<GameChoiceChallenges>(`/marathons/${marathonId}/game-choice/challenges`, {
params: { game_id: gameId },
})
return response.data
}, },
getDroppedAssignments: async (marathonId: number): Promise<DroppedAssignment[]> => { selectGameChoiceChallenge: async (marathonId: number, challengeId: number): Promise<{ message: string }> => {
const response = await client.get<DroppedAssignment[]>(`/marathons/${marathonId}/dropped-assignments`) const response = await client.post<{ message: string }>(`/marathons/${marathonId}/game-choice/select`, {
challenge_id: challengeId,
})
return response.data return response.data
}, },
@@ -64,4 +70,27 @@ export const eventsApi = {
const response = await client.get<CommonEnemyLeaderboardEntry[]>(`/marathons/${marathonId}/common-enemy-leaderboard`) const response = await client.get<CommonEnemyLeaderboardEntry[]>(`/marathons/${marathonId}/common-enemy-leaderboard`)
return response.data return response.data
}, },
// Event Assignment (Common Enemy)
getEventAssignment: async (marathonId: number): Promise<EventAssignment> => {
const response = await client.get<EventAssignment>(`/marathons/${marathonId}/event-assignment`)
return response.data
},
completeEventAssignment: async (
assignmentId: number,
data: { proof_url?: string; comment?: string; proof_file?: File }
): Promise<CompleteResult> => {
const formData = new FormData()
if (data.proof_url) formData.append('proof_url', data.proof_url)
if (data.comment) formData.append('comment', data.comment)
if (data.proof_file) formData.append('proof_file', data.proof_file)
const response = await client.post<CompleteResult>(
`/event-assignments/${assignmentId}/complete`,
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }
)
return response.data
},
} }

View File

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

View File

@@ -6,3 +6,6 @@ export { feedApi } from './feed'
export { adminApi } from './admin' export { adminApi } from './admin'
export { eventsApi } from './events' export { eventsApi } from './events'
export { challengesApi } from './challenges' export { challengesApi } from './challenges'
export { assignmentsApi } from './assignments'
export { usersApi } from './users'
export { telegramApi } from './telegram'

View File

@@ -0,0 +1,22 @@
import client from './client'
export interface TelegramLinkToken {
token: string
bot_url: string
}
export interface TelegramStatus {
telegram_id: number | null
telegram_username: string | null
}
export const telegramApi = {
generateLinkToken: async (): Promise<TelegramLinkToken> => {
const response = await client.post<TelegramLinkToken>('/telegram/generate-link-token')
return response.data
},
unlinkTelegram: async (): Promise<void> => {
await client.post('/users/me/telegram/unlink')
},
}

51
frontend/src/api/users.ts Normal file
View File

@@ -0,0 +1,51 @@
import client from './client'
import type { User, UserProfilePublic, UserStats, PasswordChangeData } from '@/types'
export interface UpdateNicknameData {
nickname: string
}
export const usersApi = {
// Получить публичный профиль пользователя со статистикой
getProfile: async (userId: number): Promise<UserProfilePublic> => {
const response = await client.get<UserProfilePublic>(`/users/${userId}/profile`)
return response.data
},
// Получить свою статистику
getMyStats: async (): Promise<UserStats> => {
const response = await client.get<UserStats>('/users/me/stats')
return response.data
},
// Обновить никнейм
updateNickname: async (data: UpdateNicknameData): Promise<User> => {
const response = await client.patch<User>('/users/me', data)
return response.data
},
// Загрузить аватар
uploadAvatar: async (file: File): Promise<User> => {
const formData = new FormData()
formData.append('file', file)
const response = await client.post<User>('/users/me/avatar', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
},
// Сменить пароль
changePassword: async (data: PasswordChangeData): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>('/users/me/password', 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

@@ -0,0 +1,344 @@
import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { feedApi } from '@/api'
import type { Activity, ActivityType } from '@/types'
import { Loader2, ChevronDown, Activity as ActivityIcon, ExternalLink, AlertTriangle, Sparkles, Zap } from 'lucide-react'
import { UserAvatar } from '@/components/ui'
import {
formatRelativeTime,
getActivityIcon,
getActivityColor,
isEventActivity,
formatActivityMessage,
} from '@/utils/activity'
interface ActivityFeedProps {
marathonId: number
className?: string
}
export interface ActivityFeedRef {
refresh: () => void
}
const ITEMS_PER_PAGE = 20
const POLL_INTERVAL = 10000 // 10 seconds
// Важные типы активности для отображения
const IMPORTANT_ACTIVITY_TYPES: ActivityType[] = [
'spin',
'complete',
'drop',
'start_marathon',
'finish_marathon',
'event_start',
'event_end',
'swap',
'rematch',
]
export const ActivityFeed = forwardRef<ActivityFeedRef, ActivityFeedProps>(
({ marathonId, className = '' }, ref) => {
const [activities, setActivities] = useState<Activity[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [total, setTotal] = useState(0)
const lastFetchRef = useRef<number>(0)
const loadActivities = useCallback(async (offset = 0, append = false) => {
try {
const response = await feedApi.get(marathonId, ITEMS_PER_PAGE * 2, offset)
// Фильтруем только важные события
const filtered = response.items.filter(item =>
IMPORTANT_ACTIVITY_TYPES.includes(item.type)
)
if (append) {
setActivities(prev => [...prev, ...filtered])
} else {
setActivities(filtered)
}
setHasMore(response.has_more)
setTotal(filtered.length)
lastFetchRef.current = Date.now()
} catch (error) {
console.error('Failed to load activity feed:', error)
}
}, [marathonId])
// Expose refresh method
useImperativeHandle(ref, () => ({
refresh: () => loadActivities()
}), [loadActivities])
// Initial load
useEffect(() => {
setIsLoading(true)
loadActivities().finally(() => setIsLoading(false))
}, [loadActivities])
// Polling for new activities
useEffect(() => {
const interval = setInterval(() => {
if (document.visibilityState === 'visible') {
loadActivities()
}
}, POLL_INTERVAL)
return () => clearInterval(interval)
}, [loadActivities])
const handleLoadMore = async () => {
setIsLoadingMore(true)
await loadActivities(activities.length, true)
setIsLoadingMore(false)
}
if (isLoading) {
return (
<div className={`glass rounded-2xl border border-dark-600 flex flex-col ${className}`}>
<div className="flex items-center gap-3 px-5 py-4 border-b border-dark-600">
<div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center">
<ActivityIcon className="w-4 h-4 text-neon-400" />
</div>
<h3 className="font-semibold text-white">Активность</h3>
</div>
<div className="flex-1 flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-neon-500" />
</div>
</div>
)
}
return (
<div className={`glass rounded-2xl border border-dark-600 flex flex-col overflow-hidden ${className}`}>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-dark-600 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center">
<Zap className="w-4 h-4 text-neon-400" />
</div>
<div>
<h3 className="font-semibold text-white">Активность</h3>
{total > 0 && (
<p className="text-xs text-gray-500">{total} событий</p>
)}
</div>
</div>
<div className="w-2 h-2 rounded-full bg-neon-500 animate-pulse" />
</div>
{/* Activity list */}
<div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar">
{activities.length === 0 ? (
<div className="px-5 py-12 text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-dark-700 flex items-center justify-center">
<Sparkles className="w-6 h-6 text-gray-600" />
</div>
<p className="text-gray-400 text-sm">Пока нет активности</p>
</div>
) : (
<div className="divide-y divide-dark-600/50">
{activities.map((activity, index) => (
<ActivityItem
key={activity.id}
activity={activity}
isNew={index === 0}
/>
))}
</div>
)}
{/* Load more button */}
{hasMore && (
<div className="p-4 border-t border-dark-600/50">
<button
onClick={handleLoadMore}
disabled={isLoadingMore}
className="w-full py-2.5 text-sm text-gray-400 hover:text-neon-400 transition-colors flex items-center justify-center gap-2 rounded-lg hover:bg-neon-500/5"
>
{isLoadingMore ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<ChevronDown className="w-4 h-4" />
Загрузить ещё
</>
)}
</button>
</div>
)}
</div>
</div>
)
}
)
ActivityFeed.displayName = 'ActivityFeed'
interface ActivityItemProps {
activity: Activity
isNew?: boolean
}
function ActivityItem({ activity, isNew }: ActivityItemProps) {
const navigate = useNavigate()
const Icon = getActivityIcon(activity.type)
const iconColor = getActivityColor(activity.type)
const isEvent = isEventActivity(activity.type)
const { title, details, extra } = formatActivityMessage(activity)
// Get assignment_id and dispute status for complete activities
const activityData = activity.data as { assignment_id?: number; dispute_status?: string } | null
const assignmentId = activity.type === 'complete' && activityData?.assignment_id
? activityData.assignment_id
: null
const disputeStatus = activity.type === 'complete' && activityData?.dispute_status
? activityData.dispute_status
: null
// Determine accent color based on activity type
const getAccentConfig = () => {
switch (activity.type) {
case 'spin':
return { border: 'border-l-accent-500', bg: 'bg-accent-500/5' }
case 'complete':
return { border: 'border-l-green-500', bg: 'bg-green-500/5' }
case 'drop':
return { border: 'border-l-red-500', bg: 'bg-red-500/5' }
case 'start_marathon':
case 'event_start':
return { border: 'border-l-yellow-500', bg: 'bg-yellow-500/5' }
case 'finish_marathon':
case 'event_end':
return { border: 'border-l-gray-500', bg: 'bg-gray-500/5' }
case 'swap':
case 'rematch':
return { border: 'border-l-neon-500', bg: 'bg-neon-500/5' }
default:
return { border: 'border-l-dark-600', bg: '' }
}
}
const accent = getAccentConfig()
if (isEvent) {
return (
<div className={`
px-5 py-4 border-l-2 transition-colors
${accent.border} ${accent.bg}
hover:bg-dark-700/30
`}>
<div className="flex items-center gap-2 mb-1.5">
<div className={`w-6 h-6 rounded-md flex items-center justify-center ${
activity.type === 'event_start' ? 'bg-yellow-500/20' : 'bg-gray-500/20'
}`}>
<Icon className={`w-3.5 h-3.5 ${iconColor}`} />
</div>
<span className={`text-sm font-semibold ${
activity.type === 'event_start' ? 'text-yellow-400' : 'text-gray-400'
}`}>
{title}
</span>
</div>
{details && (
<div className={`text-sm ml-8 ${
activity.type === 'event_start' ? 'text-yellow-200/80' : 'text-gray-500'
}`}>
{details}
</div>
)}
<div className="text-xs text-gray-600 mt-2 ml-8">
{formatRelativeTime(activity.created_at)}
</div>
</div>
)
}
return (
<div className={`
px-5 py-4 border-l-2 transition-all duration-200
${accent.border} ${isNew ? accent.bg : ''}
hover:bg-dark-700/30 group
`}>
<div className="flex items-start gap-3">
{/* Avatar */}
<Link to={`/users/${activity.user.id}`} className="flex-shrink-0 relative" onClick={(e) => e.stopPropagation()}>
<UserAvatar
userId={activity.user.id}
hasAvatar={!!activity.user.avatar_url}
nickname={activity.user.nickname}
size="sm"
/>
{/* Activity type badge */}
<div className={`
absolute -bottom-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center
border-2 border-dark-800
${activity.type === 'complete' ? 'bg-green-500' :
activity.type === 'drop' ? 'bg-red-500' :
activity.type === 'spin' ? 'bg-accent-500' :
'bg-neon-500'}
`}>
<Icon className="w-2.5 h-2.5 text-white" />
</div>
</Link>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<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}
</Link>
<span className="text-xs text-gray-600">
{formatRelativeTime(activity.created_at)}
</span>
</div>
<div className="flex items-center gap-1.5 mt-1">
<span className="text-sm text-gray-300">{title}</span>
</div>
{details && (
<div className="text-sm text-gray-500 mt-1">
{details}
</div>
)}
{extra && (
<div className="text-xs text-gray-600 mt-1">
{extra}
</div>
)}
{/* Details button and dispute indicator for complete activities */}
{assignmentId && (
<div className="flex items-center gap-3 mt-2">
<button
onClick={() => navigate(`/assignments/${assignmentId}`)}
className="text-xs text-neon-400 hover:text-neon-300 flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-neon-500/10 transition-colors"
>
<ExternalLink className="w-3 h-3" />
Детали
</button>
{disputeStatus === 'open' && (
<span className="text-xs text-orange-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-orange-500/10">
<AlertTriangle className="w-3 h-3" />
Оспаривается
</span>
)}
{disputeStatus === 'valid' && (
<span className="text-xs text-red-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-red-500/10">
<AlertTriangle className="w-3 h-3" />
Отклонено
</span>
)}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Zap, Users, Shield, Gift, ArrowLeftRight, RotateCcw, 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'
@@ -14,16 +14,58 @@ const EVENT_ICONS: Record<EventType, React.ReactNode> = {
double_risk: <Shield className="w-5 h-5" />, double_risk: <Shield className="w-5 h-5" />,
jackpot: <Gift className="w-5 h-5" />, jackpot: <Gift className="w-5 h-5" />,
swap: <ArrowLeftRight className="w-5 h-5" />, swap: <ArrowLeftRight className="w-5 h-5" />,
rematch: <RotateCcw 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
rematch: '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,9 +1,11 @@
import { useState } from 'react' import { useState } from 'react'
import { Zap, Users, Shield, Gift, ArrowLeftRight, RotateCcw, 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'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
interface EventControlProps { interface EventControlProps {
marathonId: number marathonId: number
@@ -17,17 +19,36 @@ const EVENT_TYPES: EventType[] = [
'double_risk', 'double_risk',
'jackpot', 'jackpot',
'swap', 'swap',
'rematch', 'game_choice',
'common_enemy', 'common_enemy',
] ]
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" />,
rematch: <RotateCcw 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)
const DEFAULT_DURATIONS: Record<EventType, number | null> = {
golden_hour: 45,
common_enemy: null, // Until all complete
double_risk: 120,
jackpot: null, // 1 spin
swap: 60,
game_choice: 120,
} }
export function EventControl({ export function EventControl({
@@ -36,14 +57,24 @@ export function EventControl({
challenges, challenges,
onEventChange, onEventChange,
}: EventControlProps) { }: EventControlProps) {
const toast = useToast()
const confirm = useConfirm()
const [selectedType, setSelectedType] = useState<EventType>('golden_hour') const [selectedType, setSelectedType] = useState<EventType>('golden_hour')
const [selectedChallengeId, setSelectedChallengeId] = useState<number | null>(null) const [selectedChallengeId, setSelectedChallengeId] = useState<number | null>(null)
const [durationMinutes, setDurationMinutes] = useState<number | ''>(45)
const [isStarting, setIsStarting] = useState(false) const [isStarting, setIsStarting] = useState(false)
const [isStopping, setIsStopping] = useState(false) const [isStopping, setIsStopping] = useState(false)
// Update duration when event type changes
const handleTypeChange = (type: EventType) => {
setSelectedType(type)
const defaultDuration = DEFAULT_DURATIONS[type]
setDurationMinutes(defaultDuration ?? '')
}
const handleStart = async () => { const handleStart = async () => {
if (selectedType === 'common_enemy' && !selectedChallengeId) { if (selectedType === 'common_enemy' && !selectedChallengeId) {
alert('Выберите челлендж для события "Общий враг"') toast.warning('Выберите челлендж для события "Общий враг"')
return return
} }
@@ -51,19 +82,27 @@ export function EventControl({
try { try {
await eventsApi.start(marathonId, { await eventsApi.start(marathonId, {
type: selectedType, type: selectedType,
duration_minutes: durationMinutes || undefined,
challenge_id: selectedType === 'common_enemy' ? selectedChallengeId ?? undefined : undefined, challenge_id: selectedType === 'common_enemy' ? selectedChallengeId ?? undefined : undefined,
}) })
onEventChange() onEventChange()
} catch (error) { } catch (error) {
console.error('Failed to start event:', error) console.error('Failed to start event:', error)
alert('Не удалось запустить событие') toast.error('Не удалось запустить событие')
} finally { } finally {
setIsStarting(false) setIsStarting(false)
} }
} }
const handleStop = async () => { const handleStop = async () => {
if (!confirm('Остановить событие досрочно?')) return const confirmed = await confirm({
title: 'Остановить событие?',
message: 'Событие будет завершено досрочно.',
confirmText: 'Остановить',
cancelText: 'Отмена',
variant: 'warning',
})
if (!confirmed) return
setIsStopping(true) setIsStopping(true)
try { try {
@@ -77,55 +116,103 @@ 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={() => setSelectedType(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>
</button> {isSelected && (
))} <div className="absolute top-2 right-2">
<div className={`w-2 h-2 rounded-full ${colors.icon.replace('text-', 'bg-')} animate-pulse`} />
</div> </div>
)}
</button>
)
})}
</div>
{/* Duration setting */}
{DEFAULT_DURATIONS[selectedType] !== null && (
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Длительность (минуты)
</label>
<input
type="number"
value={durationMinutes}
onChange={(e) => setDurationMinutes(e.target.value ? parseInt(e.target.value) : '')}
min={1}
max={480}
placeholder={`По умолчанию: ${DEFAULT_DURATIONS[selectedType]}`}
className="input w-full"
/>
<p className="text-xs text-gray-500 mt-1.5">
Оставьте пустым для значения по умолчанию ({DEFAULT_DURATIONS[selectedType]} мин)
</p>
</div>
)}
{selectedType === 'common_enemy' && challenges && challenges.length > 0 && ( {selectedType === 'common_enemy' && challenges && challenges.length > 0 && (
<div> <div>
@@ -135,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) => (
@@ -147,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

@@ -0,0 +1,325 @@
import { useState, useEffect, useRef } from 'react'
import { MessageCircle, ExternalLink, X, Loader2, RefreshCw, CheckCircle, User, Link2, Link2Off } from 'lucide-react'
import { telegramApi } from '@/api/telegram'
import { authApi } from '@/api/auth'
import { useAuthStore } from '@/store/auth'
export function TelegramLink() {
const { user, updateUser } = useAuthStore()
const [isOpen, setIsOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [botUrl, setBotUrl] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [isPolling, setIsPolling] = useState(false)
const [linkSuccess, setLinkSuccess] = useState(false)
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
const isLinked = !!user?.telegram_id
// Cleanup polling on unmount
useEffect(() => {
return () => {
if (pollingRef.current) {
clearInterval(pollingRef.current)
}
}
}, [])
const startPolling = () => {
setIsPolling(true)
let attempts = 0
const maxAttempts = 60 // 5 minutes (5 sec intervals)
pollingRef.current = setInterval(async () => {
attempts++
try {
const userData = await authApi.me()
if (userData.telegram_id) {
// Success! User linked their account
updateUser({
telegram_id: userData.telegram_id,
telegram_username: userData.telegram_username,
telegram_first_name: userData.telegram_first_name,
telegram_last_name: userData.telegram_last_name,
telegram_avatar_url: userData.telegram_avatar_url
})
setLinkSuccess(true)
setIsPolling(false)
setBotUrl(null)
if (pollingRef.current) {
clearInterval(pollingRef.current)
}
}
} catch {
// Ignore errors, continue polling
}
if (attempts >= maxAttempts) {
setIsPolling(false)
if (pollingRef.current) {
clearInterval(pollingRef.current)
}
}
}, 5000)
}
const stopPolling = () => {
setIsPolling(false)
if (pollingRef.current) {
clearInterval(pollingRef.current)
}
}
const handleGenerateLink = async () => {
setLoading(true)
setError(null)
setLinkSuccess(false)
try {
const { bot_url } = await telegramApi.generateLinkToken()
setBotUrl(bot_url)
} catch {
setError('Не удалось сгенерировать ссылку')
} finally {
setLoading(false)
}
}
const handleUnlink = async () => {
setLoading(true)
setError(null)
try {
await telegramApi.unlinkTelegram()
updateUser({
telegram_id: null,
telegram_username: null,
telegram_first_name: null,
telegram_last_name: null,
telegram_avatar_url: null
})
setIsOpen(false)
} catch {
setError('Не удалось отвязать аккаунт')
} finally {
setLoading(false)
}
}
const handleOpenBot = () => {
if (botUrl) {
window.open(botUrl, '_blank')
startPolling()
}
}
const handleClose = () => {
setIsOpen(false)
setBotUrl(null)
setError(null)
setLinkSuccess(false)
stopPolling()
}
return (
<>
<button
onClick={() => setIsOpen(true)}
className={`p-2 rounded-lg transition-colors ${
isLinked
? 'text-blue-400 hover:text-blue-300 hover:bg-dark-700'
: 'text-gray-400 hover:text-white hover:bg-dark-700'
}`}
title={isLinked ? 'Telegram привязан' : 'Привязать Telegram'}
>
<MessageCircle className="w-5 h-5" />
</button>
{isOpen && (
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="glass rounded-xl max-w-md w-full p-6 relative border border-dark-600">
<button
onClick={handleClose}
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-blue-500/10 rounded-full flex items-center justify-center border border-blue-500/30">
<MessageCircle className="w-6 h-6 text-blue-400" />
</div>
<div>
<h2 className="text-xl font-bold text-white">Telegram</h2>
<p className="text-sm text-gray-400">
{isLinked ? 'Аккаунт привязан' : 'Привяжи аккаунт'}
</p>
</div>
</div>
{error && (
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400 text-sm">
{error}
</div>
)}
{isLinked || linkSuccess ? (
<div className="space-y-4">
{linkSuccess && (
<div className="p-4 bg-green-500/20 border border-green-500/50 rounded-lg flex items-center gap-3">
<CheckCircle className="w-6 h-6 text-green-400 flex-shrink-0" />
<p className="text-green-400 font-medium">Аккаунт успешно привязан!</p>
</div>
)}
{/* User Profile Card */}
<div className="p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center gap-4">
{/* Avatar - Telegram avatar */}
<div className="relative">
{user?.telegram_avatar_url ? (
<img
src={user.telegram_avatar_url}
alt={user.nickname}
className="w-12 h-12 rounded-full object-cover border-2 border-blue-500/50"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-accent-500 flex items-center justify-center border-2 border-blue-500/50">
<User className="w-6 h-6 text-white" />
</div>
)}
{/* Link indicator */}
<div className="absolute -bottom-1 -right-1 w-5 h-5 bg-green-500 rounded-full flex items-center justify-center border-2 border-dark-800">
<Link2 className="w-2.5 h-2.5 text-white" />
</div>
</div>
{/* User Info */}
<div className="flex-1 min-w-0">
<p className="text-lg font-bold text-white truncate">
{[user?.telegram_first_name, user?.telegram_last_name].filter(Boolean).join(' ') || user?.nickname}
</p>
{user?.telegram_username && (
<p className="text-blue-400 font-medium truncate">@{user.telegram_username}</p>
)}
</div>
</div>
</div>
{/* Notifications Info */}
<div className="p-4 bg-dark-700/30 rounded-lg border border-dark-600/50">
<p className="text-sm text-gray-300 font-medium mb-3">Уведомления включены:</p>
<div className="grid grid-cols-1 gap-2">
<div className="flex items-center gap-2 text-sm text-gray-400">
<span className="text-yellow-400">🌟</span>
<span>События (Golden Hour, Jackpot)</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-400">
<span className="text-green-400">🚀</span>
<span>Старт и финиш марафонов</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-400">
<span className="text-red-400"></span>
<span>Споры по заданиям</span>
</div>
</div>
</div>
<button
onClick={handleUnlink}
disabled={loading}
className="w-full py-3 px-4 bg-red-500/10 hover:bg-red-500/20 text-red-400 rounded-lg font-medium transition-colors disabled:opacity-50 flex items-center justify-center gap-2 border border-red-500/30"
>
{loading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
<Link2Off className="w-4 h-4" />
Отвязать аккаунт
</>
)}
</button>
</div>
) : botUrl ? (
<div className="space-y-4">
{isPolling ? (
<>
<div className="p-4 bg-blue-500/20 border border-blue-500/50 rounded-lg">
<div className="flex items-center gap-3 mb-2">
<RefreshCw className="w-5 h-5 text-blue-400 animate-spin" />
<p className="text-blue-400 font-medium">Ожидание привязки...</p>
</div>
<p className="text-sm text-gray-400">
Открой бота в Telegram и нажми Start. Статус обновится автоматически.
</p>
</div>
<button
onClick={handleOpenBot}
className="w-full py-3 px-4 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 border border-blue-500/50 hover:border-blue-500 rounded-lg font-medium transition-all duration-200 flex items-center justify-center gap-2"
>
<ExternalLink className="w-5 h-5" />
Открыть Telegram снова
</button>
</>
) : (
<>
<p className="text-gray-300">
Нажми кнопку ниже, чтобы открыть бота и завершить привязку:
</p>
<button
onClick={handleOpenBot}
className="w-full py-3 px-4 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 border border-blue-500/50 hover:border-blue-500 rounded-lg font-medium transition-all duration-200 flex items-center justify-center gap-2"
>
<ExternalLink className="w-5 h-5" />
Открыть Telegram
</button>
<p className="text-sm text-gray-400 text-center">
Ссылка действительна 10 минут
</p>
</>
)}
</div>
) : (
<div className="space-y-4">
<p className="text-gray-300">
Привяжи Telegram, чтобы получать уведомления о важных событиях:
</p>
<ul className="text-sm text-gray-400 space-y-2">
<li className="flex items-center gap-2">
<span className="text-yellow-400">🌟</span>
Golden Hour - очки x1.5
</li>
<li className="flex items-center gap-2">
<span className="text-yellow-400">🎰</span>
Jackpot - очки x3
</li>
<li className="flex items-center gap-2">
<span className="text-yellow-400"></span>
Double Risk и другие события
</li>
</ul>
<button
onClick={handleGenerateLink}
disabled={loading}
className="w-full py-3 px-4 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 border border-blue-500/50 hover:border-blue-500 rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{loading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
<MessageCircle className="w-5 h-5" />
Привязать Telegram
</>
)}
</button>
</div>
)}
</div>
</div>
)}
</>
)
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,122 @@
import { useEffect } from 'react'
import { AlertTriangle, Info, Trash2, X } from 'lucide-react'
import { clsx } from 'clsx'
import { useConfirmStore, type ConfirmVariant } from '@/store/confirm'
import { NeonButton } from './NeonButton'
const icons: Record<ConfirmVariant, React.ReactNode> = {
danger: <Trash2 className="w-6 h-6" />,
warning: <AlertTriangle className="w-6 h-6" />,
info: <Info className="w-6 h-6" />,
}
const iconStyles: Record<ConfirmVariant, string> = {
danger: 'bg-red-500/10 text-red-400 border border-red-500/30',
warning: 'bg-yellow-500/10 text-yellow-400 border border-yellow-500/30',
info: 'bg-neon-500/10 text-neon-400 border border-neon-500/30',
}
const confirmButtonStyles: Record<ConfirmVariant, string> = {
danger: 'border-red-500/50 text-red-400 hover:bg-red-500/10 hover:border-red-500',
warning: 'border-yellow-500/50 text-yellow-400 hover:bg-yellow-500/10 hover:border-yellow-500',
info: '', // Will use NeonButton default
}
export function ConfirmModal() {
const { isOpen, options, handleConfirm, handleCancel } = useConfirmStore()
// Handle escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
handleCancel()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen, handleCancel])
// Prevent body scroll when modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isOpen])
if (!isOpen || !options) return null
const variant = options.variant || 'warning'
const Icon = icons[variant]
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm animate-in fade-in duration-200"
onClick={handleCancel}
/>
{/* Modal */}
<div className="relative glass rounded-xl shadow-2xl max-w-md w-full animate-in zoom-in-95 fade-in duration-200 border border-dark-600">
{/* Close button */}
<button
onClick={handleCancel}
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
<div className="p-6">
{/* Icon */}
<div className={clsx('w-12 h-12 rounded-full flex items-center justify-center mb-4', iconStyles[variant])}>
{Icon}
</div>
{/* Title */}
<h3 className="text-xl font-bold text-white mb-2">
{options.title}
</h3>
{/* Message */}
<p className="text-gray-400 mb-6 whitespace-pre-line">
{options.message}
</p>
{/* Actions */}
<div className="flex gap-3">
<NeonButton
variant="secondary"
className="flex-1"
onClick={handleCancel}
>
{options.cancelText || 'Отмена'}
</NeonButton>
{variant === 'info' ? (
<NeonButton
className="flex-1"
onClick={handleConfirm}
>
{options.confirmText || 'Подтвердить'}
</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>
)
}

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,83 @@
import { useEffect, useState } from 'react'
import { X, CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react'
import { clsx } from 'clsx'
import { useToastStore, type Toast as ToastType } from '@/store/toast'
const icons = {
success: CheckCircle,
error: XCircle,
warning: AlertTriangle,
info: Info,
}
const styles = {
success: 'bg-green-500/20 border-green-500/50 text-green-400',
error: 'bg-red-500/20 border-red-500/50 text-red-400',
warning: 'bg-yellow-500/20 border-yellow-500/50 text-yellow-400',
info: 'bg-blue-500/20 border-blue-500/50 text-blue-400',
}
const iconStyles = {
success: 'text-green-500',
error: 'text-red-500',
warning: 'text-yellow-500',
info: 'text-blue-500',
}
interface ToastItemProps {
toast: ToastType
onRemove: (id: string) => void
}
function ToastItem({ toast, onRemove }: ToastItemProps) {
const [isVisible, setIsVisible] = useState(false)
const [isLeaving, setIsLeaving] = useState(false)
const Icon = icons[toast.type]
useEffect(() => {
// Trigger enter animation
requestAnimationFrame(() => setIsVisible(true))
}, [])
const handleRemove = () => {
setIsLeaving(true)
setTimeout(() => onRemove(toast.id), 200)
}
return (
<div
className={clsx(
'flex items-start gap-3 p-4 rounded-lg border backdrop-blur-sm shadow-lg',
'transition-all duration-200 ease-out',
styles[toast.type],
isVisible && !isLeaving ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'
)}
>
<Icon className={clsx('w-5 h-5 flex-shrink-0 mt-0.5', iconStyles[toast.type])} />
<p className="flex-1 text-sm text-white">{toast.message}</p>
<button
onClick={handleRemove}
className="flex-shrink-0 text-gray-400 hover:text-white transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
)
}
export function ToastContainer() {
const toasts = useToastStore((state) => state.toasts)
const removeToast = useToastStore((state) => state.removeToast)
if (toasts.length === 0) return null
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
{toasts.map((toast) => (
<div key={toast.id} className="pointer-events-auto">
<ToastItem toast={toast} onRemove={removeToast} />
</div>
))}
</div>
)
}

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

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

View File

@@ -2,36 +2,582 @@
@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);
} }
/* ========================================
Base Styles
======================================== */
html {
scroll-behavior: smooth;
}
body {
@apply bg-dark-900 text-gray-100 min-h-screen antialiased;
font-family: 'Inter', system-ui, sans-serif;
background-image:
linear-gradient(rgba(34, 211, 238, 0.015) 1px, transparent 1px),
linear-gradient(90deg, rgba(34, 211, 238, 0.015) 1px, transparent 1px);
background-size: 50px 50px;
background-attachment: fixed;
}
/* Noise overlay - can be added to any element */
.noise-overlay::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.03;
pointer-events: none;
z-index: 9999;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
}
/* Autofill styles - override browser defaults */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0 30px #14161e inset !important;
-webkit-text-fill-color: #fff !important;
caret-color: #fff;
transition: background-color 5000s ease-in-out 0s;
}
/* ========================================
Selection Styles
======================================== */
::selection {
background: rgba(34, 211, 238, 0.25);
color: #fff;
}
::-moz-selection {
background: rgba(34, 211, 238, 0.25);
color: #fff;
}
/* ========================================
Custom Scrollbar (Neon Style)
======================================== */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-dark-800);
}
::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, var(--color-neon-500), var(--color-accent-500));
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, var(--color-neon-400), var(--color-accent-600));
}
::-webkit-scrollbar-corner {
background: var(--color-dark-800);
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-neon-500) var(--color-dark-800);
}
/* Custom scrollbar class for specific elements */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--color-dark-500);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--color-neon-500);
}
/* ========================================
Glitch Effect
======================================== */
.glitch {
position: relative;
animation: glitch-skew 1s infinite linear alternate-reverse;
}
.glitch::before,
.glitch::after {
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.glitch::before {
left: 2px;
text-shadow: -2px 0 rgba(139, 92, 246, 0.7);
clip: rect(44px, 450px, 56px, 0);
animation: glitch-anim 5s infinite linear alternate-reverse;
}
.glitch::after {
left: -2px;
text-shadow: -2px 0 rgba(34, 211, 238, 0.7), 2px 2px rgba(139, 92, 246, 0.7);
clip: rect(44px, 450px, 56px, 0);
animation: glitch-anim2 5s infinite linear alternate-reverse;
}
@keyframes glitch-anim {
0% { clip: rect(31px, 9999px, 94px, 0); transform: skew(0.85deg); }
5% { clip: rect(70px, 9999px, 71px, 0); transform: skew(0.07deg); }
10% { clip: rect(29px, 9999px, 24px, 0); transform: skew(0.22deg); }
15% { clip: rect(69px, 9999px, 63px, 0); transform: skew(0.52deg); }
20% { clip: rect(13px, 9999px, 71px, 0); transform: skew(0.72deg); }
25% { clip: rect(39px, 9999px, 89px, 0); transform: skew(0.24deg); }
30% { clip: rect(87px, 9999px, 98px, 0); transform: skew(0.63deg); }
35% { clip: rect(63px, 9999px, 16px, 0); transform: skew(0.15deg); }
40% { clip: rect(92px, 9999px, 4px, 0); transform: skew(0.83deg); }
45% { clip: rect(67px, 9999px, 72px, 0); transform: skew(0.19deg); }
50% { clip: rect(43px, 9999px, 21px, 0); transform: skew(0.74deg); }
55% { clip: rect(75px, 9999px, 54px, 0); transform: skew(0.28deg); }
60% { clip: rect(17px, 9999px, 86px, 0); transform: skew(0.91deg); }
65% { clip: rect(51px, 9999px, 32px, 0); transform: skew(0.46deg); }
70% { clip: rect(29px, 9999px, 69px, 0); transform: skew(0.38deg); }
75% { clip: rect(84px, 9999px, 11px, 0); transform: skew(0.67deg); }
80% { clip: rect(38px, 9999px, 82px, 0); transform: skew(0.12deg); }
85% { clip: rect(61px, 9999px, 47px, 0); transform: skew(0.54deg); }
90% { clip: rect(22px, 9999px, 91px, 0); transform: skew(0.33deg); }
95% { clip: rect(79px, 9999px, 28px, 0); transform: skew(0.79deg); }
100% { clip: rect(56px, 9999px, 65px, 0); transform: skew(0.41deg); }
}
@keyframes glitch-anim2 {
0% { clip: rect(65px, 9999px, 100px, 0); transform: skew(0.63deg); }
5% { clip: rect(52px, 9999px, 74px, 0); transform: skew(0.29deg); }
10% { clip: rect(79px, 9999px, 85px, 0); transform: skew(0.84deg); }
15% { clip: rect(43px, 9999px, 27px, 0); transform: skew(0.17deg); }
20% { clip: rect(16px, 9999px, 92px, 0); transform: skew(0.56deg); }
25% { clip: rect(88px, 9999px, 36px, 0); transform: skew(0.39deg); }
30% { clip: rect(32px, 9999px, 68px, 0); transform: skew(0.71deg); }
35% { clip: rect(71px, 9999px, 13px, 0); transform: skew(0.23deg); }
40% { clip: rect(24px, 9999px, 57px, 0); transform: skew(0.92deg); }
45% { clip: rect(83px, 9999px, 41px, 0); transform: skew(0.48deg); }
50% { clip: rect(19px, 9999px, 79px, 0); transform: skew(0.35deg); }
55% { clip: rect(67px, 9999px, 23px, 0); transform: skew(0.76deg); }
60% { clip: rect(45px, 9999px, 96px, 0); transform: skew(0.14deg); }
65% { clip: rect(91px, 9999px, 51px, 0); transform: skew(0.58deg); }
70% { clip: rect(28px, 9999px, 83px, 0); transform: skew(0.87deg); }
75% { clip: rect(76px, 9999px, 19px, 0); transform: skew(0.26deg); }
80% { clip: rect(53px, 9999px, 67px, 0); transform: skew(0.69deg); }
85% { clip: rect(14px, 9999px, 89px, 0); transform: skew(0.43deg); }
90% { clip: rect(62px, 9999px, 34px, 0); transform: skew(0.81deg); }
95% { clip: rect(37px, 9999px, 76px, 0); transform: skew(0.52deg); }
100% { clip: rect(86px, 9999px, 48px, 0); transform: skew(0.31deg); }
}
@keyframes glitch-skew {
0% { transform: skew(-2deg); }
10% { transform: skew(1deg); }
20% { transform: skew(-1deg); }
30% { transform: skew(0deg); }
40% { transform: skew(2deg); }
50% { transform: skew(-1deg); }
60% { transform: skew(1deg); }
70% { transform: skew(0deg); }
80% { transform: skew(-2deg); }
90% { transform: skew(1deg); }
100% { transform: skew(0deg); }
}
/* Simpler glitch for hover states */
.glitch-hover:hover {
animation: glitch-simple 0.3s ease;
}
@keyframes glitch-simple {
0%, 100% { transform: translate(0); }
20% { transform: translate(-2px, 2px); }
40% { transform: translate(-2px, -2px); }
60% { transform: translate(2px, 2px); }
80% { transform: translate(2px, -2px); }
}
/* ========================================
Neon Glow Effects
======================================== */
.neon-glow {
box-shadow: var(--glow-neon);
}
.neon-glow-lg {
box-shadow: var(--glow-neon-lg);
}
.neon-glow-purple {
box-shadow: var(--glow-purple);
}
.neon-glow-pink {
box-shadow: var(--glow-pink);
}
.neon-text {
text-shadow: var(--text-glow-neon);
}
.neon-text-purple {
text-shadow: var(--text-glow-purple);
}
/* Animated glow */
.neon-glow-pulse {
animation: neon-pulse 2s ease-in-out infinite;
}
@keyframes neon-pulse {
0%, 100% {
box-shadow: 0 0 6px rgba(34, 211, 238, 0.4), 0 0 12px rgba(34, 211, 238, 0.2);
}
50% {
box-shadow: 0 0 10px rgba(34, 211, 238, 0.5), 0 0 20px rgba(34, 211, 238, 0.3);
}
}
/* ========================================
Glass Effect (Glassmorphism)
======================================== */
.glass {
background: rgba(18, 18, 26, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.glass-dark {
background: rgba(10, 10, 15, 0.8);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.glass-neon {
background: rgba(20, 22, 30, 0.6);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(34, 211, 238, 0.15);
box-shadow: inset 0 0 20px rgba(34, 211, 238, 0.03);
}
/* ========================================
Gradient Utilities
======================================== */
.gradient-neon {
background: linear-gradient(135deg, #22d3ee, #8b5cf6);
}
.gradient-neon-text {
background: linear-gradient(135deg, #22d3ee, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.gradient-pink-purple {
background: linear-gradient(135deg, #f472b6, #8b5cf6);
}
.gradient-dark {
background: linear-gradient(180deg, var(--color-dark-900), var(--color-dark-950));
}
/* Animated gradient border */
.gradient-border {
position: relative;
background: var(--color-dark-800);
border-radius: 12px;
}
.gradient-border::before {
content: '';
position: absolute;
inset: -2px;
background: linear-gradient(90deg, #22d3ee, #8b5cf6, #f472b6, #22d3ee);
background-size: 300% 300%;
border-radius: 14px;
z-index: -1;
animation: gradient-flow 3s linear infinite;
}
@keyframes gradient-flow {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* ========================================
Shimmer Effect
======================================== */
.shimmer {
position: relative;
overflow: hidden;
}
.shimmer::after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.1),
transparent
);
animation: shimmer 2s infinite;
}
@keyframes shimmer {
100% { left: 100%; }
}
/* ========================================
Component Layer
======================================== */
@layer components { @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

@@ -0,0 +1,553 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { assignmentsApi } from '@/api'
import type { AssignmentDetail } from '@/types'
import { GlassCard, NeonButton } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import { useToast } from '@/store/toast'
import {
ArrowLeft, Loader2, ExternalLink, Image, MessageSquare,
ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle,
Send, Flag, Gamepad2, Zap, Trophy
} from 'lucide-react'
export function AssignmentDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const user = useAuthStore((state) => state.user)
const toast = useToast()
const [assignment, setAssignment] = useState<AssignmentDetail | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [proofMediaBlobUrl, setProofMediaBlobUrl] = useState<string | null>(null)
const [proofMediaType, setProofMediaType] = useState<'image' | 'video' | null>(null)
// Dispute creation
const [showDisputeForm, setShowDisputeForm] = useState(false)
const [disputeReason, setDisputeReason] = useState('')
const [isCreatingDispute, setIsCreatingDispute] = useState(false)
// Comment
const [commentText, setCommentText] = useState('')
const [isAddingComment, setIsAddingComment] = useState(false)
// Voting
const [isVoting, setIsVoting] = useState(false)
useEffect(() => {
loadAssignment()
return () => {
// Cleanup blob URL on unmount
if (proofMediaBlobUrl) {
URL.revokeObjectURL(proofMediaBlobUrl)
}
}
}, [id])
const loadAssignment = async () => {
if (!id) return
setIsLoading(true)
setError(null)
try {
const data = await assignmentsApi.getDetail(parseInt(id))
setAssignment(data)
// Load proof media if exists
if (data.proof_image_url) {
try {
const { url, type } = await assignmentsApi.getProofMediaUrl(parseInt(id))
setProofMediaBlobUrl(url)
setProofMediaType(type)
} catch {
// Ignore error, media just won't show
}
}
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
setError(error.response?.data?.detail || 'Не удалось загрузить данные')
} finally {
setIsLoading(false)
}
}
const handleCreateDispute = async () => {
if (!id || !disputeReason.trim()) return
setIsCreatingDispute(true)
try {
await assignmentsApi.createDispute(parseInt(id), disputeReason)
setDisputeReason('')
setShowDisputeForm(false)
await loadAssignment()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось создать оспаривание')
} finally {
setIsCreatingDispute(false)
}
}
const handleVote = async (vote: boolean) => {
if (!assignment?.dispute) return
setIsVoting(true)
try {
await assignmentsApi.vote(assignment.dispute.id, vote)
await loadAssignment()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось проголосовать')
} finally {
setIsVoting(false)
}
}
const handleAddComment = async () => {
if (!assignment?.dispute || !commentText.trim()) return
setIsAddingComment(true)
try {
await assignmentsApi.addComment(assignment.dispute.id, commentText)
setCommentText('')
await loadAssignment()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось добавить комментарий')
} finally {
setIsAddingComment(false)
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const getTimeRemaining = (expiresAt: string) => {
const now = new Date()
const expires = new Date(expiresAt)
const diff = expires.getTime() - now.getTime()
if (diff <= 0) return 'Истекло'
const hours = Math.floor(diff / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
return `${hours}ч ${minutes}м`
}
const getStatusConfig = (status: string) => {
switch (status) {
case 'completed':
return {
color: 'bg-green-500/20 text-green-400 border-green-500/30',
icon: <CheckCircle className="w-4 h-4" />,
text: 'Выполнено',
}
case 'dropped':
return {
color: 'bg-red-500/20 text-red-400 border-red-500/30',
icon: <XCircle className="w-4 h-4" />,
text: 'Пропущено',
}
case 'returned':
return {
color: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
icon: <AlertTriangle className="w-4 h-4" />,
text: 'Возвращено',
}
default:
return {
color: 'bg-neon-500/20 text-neon-400 border-neon-500/30',
icon: <Zap className="w-4 h-4" />,
text: 'Активно',
}
}
}
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center py-24">
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
<p className="text-gray-400">Загрузка...</p>
</div>
)
}
if (error || !assignment) {
return (
<div className="max-w-2xl mx-auto">
<GlassCard className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-red-500/10 border border-red-500/30 flex items-center justify-center">
<AlertTriangle className="w-8 h-8 text-red-400" />
</div>
<p className="text-gray-400 mb-6">{error || 'Задание не найдено'}</p>
<NeonButton variant="outline" onClick={() => navigate(-1)}>
Назад
</NeonButton>
</GlassCard>
</div>
)
}
const dispute = assignment.dispute
const status = getStatusConfig(assignment.status)
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<button
onClick={() => navigate(-1)}
className="w-10 h-10 rounded-xl bg-dark-700 border border-dark-600 flex items-center justify-center text-gray-400 hover:text-white hover:border-neon-500/30 transition-all"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<h1 className="text-xl font-bold text-white">Детали выполнения</h1>
<p className="text-sm text-gray-400">Просмотр доказательства</p>
</div>
</div>
{/* Challenge info */}
<GlassCard variant="neon">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 border border-neon-500/20 flex items-center justify-center">
<Gamepad2 className="w-7 h-7 text-neon-400" />
</div>
<div>
<p className="text-gray-400 text-sm">{assignment.challenge.game.title}</p>
<h2 className="text-xl font-bold text-white">{assignment.challenge.title}</h2>
</div>
</div>
<span className={`px-3 py-1.5 rounded-full text-sm font-medium border flex items-center gap-1.5 ${status.color}`}>
{status.icon}
{status.text}
</span>
</div>
<p className="text-gray-300 mb-4">{assignment.challenge.description}</p>
<div className="flex flex-wrap gap-2 mb-4">
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30 flex items-center gap-1.5">
<Trophy className="w-4 h-4" />
+{assignment.challenge.points} очков
</span>
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
{assignment.challenge.difficulty}
</span>
{assignment.challenge.estimated_time && (
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600 flex items-center gap-1.5">
<Clock className="w-4 h-4" />
~{assignment.challenge.estimated_time} мин
</span>
)}
</div>
<div className="pt-4 border-t border-dark-600 text-sm text-gray-400 space-y-1">
<p>
<span className="text-gray-500">Выполнил:</span>{' '}
<span className="text-white">{assignment.participant.nickname}</span>
</p>
{assignment.completed_at && (
<p>
<span className="text-gray-500">Дата:</span>{' '}
<span className="text-white">{formatDate(assignment.completed_at)}</span>
</p>
)}
{assignment.points_earned > 0 && (
<p>
<span className="text-gray-500">Получено очков:</span>{' '}
<span className="text-neon-400 font-semibold">{assignment.points_earned}</span>
</p>
)}
</div>
</GlassCard>
{/* Proof section */}
<GlassCard>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
<Image className="w-5 h-5 text-accent-400" />
</div>
<div>
<h3 className="font-semibold text-white">Доказательство</h3>
<p className="text-sm text-gray-400">Пруф выполнения задания</p>
</div>
</div>
{/* Proof media (image or video) */}
{assignment.proof_image_url && (
<div className="mb-4 rounded-xl overflow-hidden border border-dark-600">
{proofMediaBlobUrl ? (
proofMediaType === 'video' ? (
<video
src={proofMediaBlobUrl}
controls
className="w-full max-h-96 bg-dark-900"
preload="metadata"
/>
) : (
<img
src={proofMediaBlobUrl}
alt="Proof"
className="w-full max-h-96 object-contain bg-dark-900"
/>
)
) : (
<div className="w-full h-48 bg-dark-900 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-gray-500" />
</div>
)}
</div>
)}
{/* Proof URL */}
{assignment.proof_url && (
<div className="mb-4">
<a
href={assignment.proof_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-neon-400 hover:text-neon-300 transition-colors"
>
<ExternalLink className="w-4 h-4" />
{assignment.proof_url}
</a>
</div>
)}
{/* Proof comment */}
{assignment.proof_comment && (
<div className="p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<p className="text-sm text-gray-400 mb-1">Комментарий:</p>
<p className="text-white">{assignment.proof_comment}</p>
</div>
)}
{!assignment.proof_image_url && !assignment.proof_url && (
<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>
)}
</GlassCard>
{/* Dispute button */}
{assignment.can_dispute && !dispute && !showDisputeForm && (
<button
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-semibold transition-all duration-200 border-2 border-red-500/50 text-red-400 bg-transparent hover:bg-red-500/10 hover:border-red-500"
onClick={() => setShowDisputeForm(true)}
>
<Flag className="w-4 h-4" />
Оспорить выполнение
</button>
)}
{/* Dispute creation form */}
{showDisputeForm && !dispute && (
<GlassCard className="border-red-500/30">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-red-500/20 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-red-400" />
</div>
<div>
<h3 className="font-semibold text-red-400">Оспорить выполнение</h3>
<p className="text-sm text-gray-400">У участников будет 24 часа для голосования</p>
</div>
</div>
<textarea
className="input w-full min-h-[100px] resize-none mb-4"
placeholder="Причина оспаривания (минимум 10 символов)..."
value={disputeReason}
onChange={(e) => setDisputeReason(e.target.value)}
/>
<div className="flex gap-3">
<NeonButton
className="flex-1 border-red-500/50 bg-red-500/10 hover:bg-red-500/20 text-red-400"
onClick={handleCreateDispute}
isLoading={isCreatingDispute}
disabled={disputeReason.trim().length < 10}
>
Оспорить
</NeonButton>
<NeonButton
variant="outline"
onClick={() => {
setShowDisputeForm(false)
setDisputeReason('')
}}
>
Отмена
</NeonButton>
</div>
</GlassCard>
)}
{/* Dispute section */}
{dispute && (
<GlassCard className={dispute.status === 'open' ? 'border-yellow-500/30' : ''}>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-yellow-400" />
</div>
<h3 className="font-semibold text-yellow-400">Оспаривание</h3>
</div>
{dispute.status === 'open' ? (
<span className="px-3 py-1.5 bg-yellow-500/20 text-yellow-400 rounded-lg text-sm font-medium border border-yellow-500/30 flex items-center gap-1.5">
<Clock className="w-4 h-4" />
{getTimeRemaining(dispute.expires_at)}
</span>
) : dispute.status === 'valid' ? (
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30 flex items-center gap-1.5">
<CheckCircle className="w-4 h-4" />
Пруф валиден
</span>
) : (
<span className="px-3 py-1.5 bg-red-500/20 text-red-400 rounded-lg text-sm font-medium border border-red-500/30 flex items-center gap-1.5">
<XCircle className="w-4 h-4" />
Пруф невалиден
</span>
)}
</div>
<div className="mb-4 text-sm text-gray-400">
<p>
Открыл: <span className="text-white">{dispute.raised_by.nickname}</span>
</p>
<p>
Дата: <span className="text-white">{formatDate(dispute.created_at)}</span>
</p>
</div>
<div className="p-4 bg-dark-700/50 rounded-xl border border-dark-600 mb-4">
<p className="text-sm text-gray-400 mb-1">Причина:</p>
<p className="text-white">{dispute.reason}</p>
</div>
{/* Voting section */}
{dispute.status === 'open' && (
<div className="mb-6 p-4 bg-dark-700/30 rounded-xl border border-dark-600">
<h4 className="text-sm font-semibold text-white mb-4">Голосование</h4>
<div className="flex items-center gap-6 mb-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-green-500/20 flex items-center justify-center">
<ThumbsUp className="w-4 h-4 text-green-400" />
</div>
<span className="text-green-400 font-bold text-lg">{dispute.votes_valid}</span>
<span className="text-gray-500 text-sm">валидно</span>
</div>
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-red-500/20 flex items-center justify-center">
<ThumbsDown className="w-4 h-4 text-red-400" />
</div>
<span className="text-red-400 font-bold text-lg">{dispute.votes_invalid}</span>
<span className="text-gray-500 text-sm">невалидно</span>
</div>
</div>
<div className="flex gap-3">
<NeonButton
className={`flex-1 ${dispute.my_vote === true ? 'bg-green-500/20 border-green-500/50 text-green-400' : ''}`}
variant="outline"
onClick={() => handleVote(true)}
isLoading={isVoting}
disabled={isVoting}
icon={<ThumbsUp className="w-4 h-4" />}
>
Валидно
</NeonButton>
<NeonButton
className={`flex-1 ${dispute.my_vote === false ? 'bg-red-500/20 border-red-500/50 text-red-400' : ''}`}
variant="outline"
onClick={() => handleVote(false)}
isLoading={isVoting}
disabled={isVoting}
icon={<ThumbsDown className="w-4 h-4" />}
>
Невалидно
</NeonButton>
</div>
{dispute.my_vote !== null && (
<p className="text-sm text-gray-500 mt-3 text-center">
Вы проголосовали: <span className={dispute.my_vote ? 'text-green-400' : 'text-red-400'}>
{dispute.my_vote ? 'валидно' : 'невалидно'}
</span>
</p>
)}
</div>
)}
{/* Comments section */}
<div>
<div className="flex items-center gap-2 mb-4">
<MessageSquare className="w-4 h-4 text-gray-400" />
<h4 className="text-sm font-semibold text-white">
Обсуждение ({dispute.comments.length})
</h4>
</div>
{dispute.comments.length > 0 && (
<div className="space-y-3 mb-4 max-h-60 overflow-y-auto custom-scrollbar">
{dispute.comments.map((comment) => (
<div key={comment.id} className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-1">
<span className={`font-medium ${comment.user.id === user?.id ? 'text-neon-400' : 'text-white'}`}>
{comment.user.nickname}
{comment.user.id === user?.id && ' (Вы)'}
</span>
<span className="text-xs text-gray-500">
{formatDate(comment.created_at)}
</span>
</div>
<p className="text-gray-300 text-sm">{comment.text}</p>
</div>
))}
</div>
)}
{/* Add comment form */}
{dispute.status === 'open' && (
<div className="flex gap-2">
<input
type="text"
className="input flex-1"
placeholder="Написать комментарий..."
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleAddComment()
}
}}
/>
<NeonButton
onClick={handleAddComment}
isLoading={isAddingComment}
disabled={!commentText.trim()}
icon={<Send className="w-4 h-4" />}
/>
</div>
)}
</div>
</GlassCard>
)}
</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 } from 'lucide-react'
const loginSchema = z.object({ const loginSchema = z.object({
login: z.string().min(3, 'Логин должен быть не менее 3 символов'), login: z.string().min(3, 'Логин должен быть не менее 3 символов'),
@@ -51,17 +52,79 @@ export function LoginPage() {
} }
} }
const features = [
{ icon: <Trophy className="w-5 h-5" />, text: 'Соревнуйтесь с друзьями' },
{ icon: <Target className="w-5 h-5" />, text: 'Выполняйте челленджи' },
{ icon: <Zap className="w-5 h-5" />, text: 'Зарабатывайте очки' },
{ icon: <Users className="w-5 h-5" />, text: 'Создавайте марафоны' },
]
return ( return (
<div className="max-w-md mx-auto"> <div className="min-h-[80vh] flex items-center justify-center px-4 -mt-8">
<Card> {/* Background effects */}
<CardHeader> <div className="fixed inset-0 overflow-hidden pointer-events-none">
<CardTitle className="text-center">Вход</CardTitle> <div className="absolute top-1/4 -left-32 w-96 h-96 bg-neon-500/10 rounded-full blur-[100px]" />
</CardHeader> <div className="absolute bottom-1/4 -right-32 w-96 h-96 bg-accent-500/10 rounded-full blur-[100px]" />
<CardContent> </div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Bento Grid */}
<div className="relative w-full max-w-4xl">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-scale-in">
{/* Branding Block (left) */}
<GlassCard className="p-8 flex flex-col justify-center relative overflow-hidden" variant="neon">
{/* Background decoration */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-20 -left-20 w-48 h-48 bg-neon-500/20 rounded-full blur-[60px]" />
<div className="absolute -bottom-20 -right-20 w-48 h-48 bg-accent-500/20 rounded-full blur-[60px]" />
</div>
<div className="relative">
{/* Logo */}
<div className="flex justify-center md:justify-start mb-6">
<div className="w-20 h-20 rounded-2xl bg-neon-500/10 border border-neon-500/30 flex items-center justify-center shadow-[0_0_24px_rgba(34,211,238,0.25)]">
<Gamepad2 className="w-10 h-10 text-neon-500" />
</div>
</div>
{/* Title */}
<h1 className="text-3xl md:text-4xl font-bold text-white mb-2 text-center md:text-left">
Game Marathon
</h1>
<p className="text-gray-400 mb-8 text-center md:text-left">
Платформа для игровых соревнований
</p>
{/* Features */}
<div className="grid grid-cols-2 gap-3">
{features.map((feature, index) => (
<div
key={index}
className="flex items-center gap-2 p-3 rounded-xl bg-dark-700/50 border border-dark-600"
>
<div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center text-neon-400">
{feature.icon}
</div>
<span className="text-sm text-gray-300">{feature.text}</span>
</div>
))}
</div>
</div>
</GlassCard>
{/* Form Block (right) */}
<GlassCard className="p-8">
{/* Header */}
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-white mb-2">Добро пожаловать!</h2>
<p className="text-gray-400">Войдите, чтобы продолжить</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{(submitError || error) && ( {(submitError || error) && (
<div className="p-3 bg-red-500/20 border border-red-500 rounded-lg text-red-500 text-sm"> <div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
{submitError || error} <AlertCircle className="w-5 h-5 flex-shrink-0" />
<span>{submitError || error}</span>
</div> </div>
)} )}
@@ -69,6 +132,7 @@ export function LoginPage() {
label="Логин" label="Логин"
placeholder="Введите логин" placeholder="Введите логин"
error={errors.login?.message} error={errors.login?.message}
autoComplete="username"
{...register('login')} {...register('login')}
/> />
@@ -77,22 +141,40 @@ export function LoginPage() {
type="password" type="password"
placeholder="Введите пароль" placeholder="Введите пароль"
error={errors.password?.message} error={errors.password?.message}
autoComplete="current-password"
{...register('password')} {...register('password')}
/> />
<Button type="submit" className="w-full" isLoading={isLoading}> <NeonButton
type="submit"
className="w-full"
size="lg"
isLoading={isLoading}
icon={<LogIn className="w-5 h-5" />}
>
Войти Войти
</Button> </NeonButton>
</form>
<p className="text-center text-gray-400 text-sm"> {/* Footer */}
<div className="mt-6 pt-6 border-t border-dark-600 text-center">
<p className="text-gray-400 text-sm">
Нет аккаунта?{' '} Нет аккаунта?{' '}
<Link to="/register" className="link"> <Link
to="/register"
className="text-neon-400 hover:text-neon-300 transition-colors font-medium"
>
Зарегистрироваться Зарегистрироваться
</Link> </Link>
</p> </p>
</form> </div>
</CardContent> </GlassCard>
</Card> </div>
{/* Decorative elements */}
<div className="absolute -top-4 -right-4 w-24 h-24 border border-neon-500/20 rounded-2xl -z-10 hidden md:block" />
<div className="absolute -bottom-4 -left-4 w-32 h-32 border border-accent-500/20 rounded-2xl -z-10 hidden md:block" />
</div>
</div> </div>
) )
} }

View File

@@ -1,18 +1,28 @@
import { useState, useEffect } from 'react' 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 { 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 { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2, Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap } from 'lucide-react' 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,
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 }>()
const navigate = useNavigate() const navigate = useNavigate()
const user = useAuthStore((state) => state.user) const user = useAuthStore((state) => state.user)
const toast = useToast()
const confirm = useConfirm()
const [marathon, setMarathon] = useState<Marathon | null>(null) const [marathon, setMarathon] = useState<Marathon | null>(null)
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null) const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
const [challenges, setChallenges] = useState<Challenge[]>([]) const [challenges, setChallenges] = useState<Challenge[]>([])
@@ -20,7 +30,11 @@ export function MarathonPage() {
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const [isJoining, setIsJoining] = useState(false) const [isJoining, setIsJoining] = 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)
useEffect(() => { useEffect(() => {
loadMarathon() loadMarathon()
@@ -32,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)
@@ -46,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')
@@ -60,6 +71,7 @@ export function MarathonPage() {
try { try {
const eventData = await eventsApi.getActive(parseInt(id)) const eventData = await eventsApi.getActive(parseInt(id))
setActiveEvent(eventData) setActiveEvent(eventData)
activityFeedRef.current?.refresh()
} catch (error) { } catch (error) {
console.error('Failed to refresh event:', error) console.error('Failed to refresh event:', error)
} }
@@ -79,7 +91,16 @@ export function MarathonPage() {
} }
const handleDelete = async () => { const handleDelete = async () => {
if (!marathon || !confirm('Вы уверены, что хотите удалить этот марафон? Это действие нельзя отменить.')) return if (!marathon) return
const confirmed = await confirm({
title: 'Удалить марафон?',
message: 'Все данные марафона будут удалены безвозвратно.',
confirmText: 'Удалить',
cancelText: 'Отмена',
variant: 'danger',
})
if (!confirmed) return
setIsDeleting(true) setIsDeleting(true)
try { try {
@@ -87,7 +108,7 @@ export function MarathonPage() {
navigate('/marathons') navigate('/marathons')
} catch (error) { } catch (error) {
console.error('Failed to delete marathon:', error) console.error('Failed to delete marathon:', error)
alert('Не удалось удалить марафон') toast.error('Не удалось удалить марафон')
} finally { } finally {
setIsDeleting(false) setIsDeleting(false)
} }
@@ -102,16 +123,42 @@ export function MarathonPage() {
setMarathon(updated) setMarathon(updated)
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
alert(error.response?.data?.detail || 'Не удалось присоединиться') toast.error(error.response?.data?.detail || 'Не удалось присоединиться')
} finally { } finally {
setIsJoining(false) setIsJoining(false)
} }
} }
const handleFinish = async () => {
if (!marathon) return
const confirmed = await confirm({
title: 'Завершить марафон?',
message: 'Марафон будет завершён досрочно. Участники больше не смогут выполнять задания.',
confirmText: 'Завершить',
cancelText: 'Отмена',
variant: 'warning',
})
if (!confirmed) return
setIsFinishing(true)
try {
const updated = await marathonsApi.finish(marathon.id)
setMarathon(updated)
toast.success('Марафон завершён')
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось завершить марафон')
} finally {
setIsFinishing(false)
}
}
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>
) )
} }
@@ -121,244 +168,367 @@ 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-4xl 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>
{/* Header */} {/* Hero Banner */}
<div className="flex justify-between items-start mb-8"> <div className="relative rounded-2xl overflow-hidden mb-8">
<div> {/* Background */}
<div className="flex items-center gap-3 mb-2"> <div className="absolute inset-0 bg-gradient-to-br from-neon-500/10 via-dark-800 to-accent-500/10" />
<h1 className="text-3xl font-bold text-white">{marathon.title}</h1> <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]" />
<span className={`flex items-center gap-1 text-xs px-2 py-1 rounded ${
<div className="relative p-8">
<div className="flex flex-col md:flex-row justify-between items-start gap-6">
{/* Title & Description */}
<div className="flex-1">
<div className="flex flex-wrap items-center gap-3 mb-3">
<h1 className="text-3xl md:text-4xl font-bold text-white">{marathon.title}</h1>
<span className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border ${
marathon.is_public 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"> {/* 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 && (
<button
onClick={handleFinish}
disabled={isFinishing}
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-semibold transition-all duration-200 border border-yellow-500/30 bg-dark-600 text-yellow-400 hover:bg-yellow-500/10 hover:border-yellow-500/50 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isFinishing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Flag className="w-4 h-4" />}
Завершить
</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 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>
) )
})}
</div>
)}
</GlassCard>
)}
</div>
{/* Activity Feed - right sidebar */}
{isParticipant && (
<div className="lg:w-96 flex-shrink-0">
<div className="lg:sticky lg:top-24">
<ActivityFeed
ref={activityFeedRef}
marathonId={marathon.id}
className="lg:max-h-[calc(100vh-8rem)]"
/>
</div>
</div>
)}
</div>
</div>
)
} }

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