Compare commits

24 Commits

Author SHA1 Message Date
7a3576aec0 a 2026-01-03 00:43:26 +07:00
d295ff2aff Исключить игры типа Прохождение из проверки челленджей
При старте марафона теперь проверяются только игры с типом challenges.
Игры с типом playthrough не требуют наличия челленджей.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-30 23:02:24 +03:00
1e751f7af3 Увеличен лимит очков до 1000 и добавлена документация
- Максимум очков за челлендж/прохождение: 500 → 1000
- Добавлена документация по системе типов игр (docs/game-types.md)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-30 19:49:12 +03:00
89dbe2c018 Улучшение системы оспариваний и исправления
- Оспаривания теперь требуют решения админа после 24ч голосования
  - Можно повторно оспаривать после разрешённых споров
  - Исправлены бонусные очки при перепрохождении после оспаривания
  - Сброс серии при невалидном пруфе
  - Колесо показывает только доступные игры
  - Rate limiting только через backend (RATE_LIMIT_ENABLED)
2025-12-29 22:23:34 +03:00
1cedfeb3ee Fix migration 2025-12-21 04:39:08 +07:00
1e723e7bcd Fix migration 2025-12-21 04:22:59 +07:00
a513dc2207 Filters 2025-12-21 04:13:20 +07:00
6bc35fc0bb http checking 2025-12-21 03:46:37 +07:00
d3adf07c3f Add covers 2025-12-21 03:05:57 +07:00
921917a319 Add covers 2025-12-21 02:52:48 +07:00
9d2dba87b8 Fix wheel 2025-12-21 00:33:25 +07:00
95e2a77335 Fix ban screen 2025-12-20 23:59:13 +07:00
6c824712c9 Fix status--service 2025-12-20 22:41:26 +07:00
5c073705d8 change backup service 2025-12-20 22:30:18 +07:00
243abe55b5 Fix service status 2025-12-20 02:28:41 +07:00
c645171671 Add static pages and styles 2025-12-20 02:01:51 +07:00
07745ea4ed Add TG banner 2025-12-20 01:07:24 +07:00
22385e8742 Fix auth refresh 2025-12-20 00:43:36 +07:00
a77a757317 Add reset password to admin panel 2025-12-20 00:34:22 +07:00
2d281d1c8c Add search and fetch user account 2025-12-20 00:17:58 +07:00
13f484e726 Fix migrations 2 2025-12-19 02:28:02 +07:00
ebaf6d39ea Fix migrations 2025-12-19 02:23:50 +07:00
481bdabaa8 Add admin panel 2025-12-19 02:07:25 +07:00
8e634994bd Add challenges promotion 2025-12-18 23:47:11 +07:00
105 changed files with 13814 additions and 1154 deletions

View File

@@ -32,3 +32,5 @@ PUBLIC_URL=https://your-domain.com
# Frontend (for build) # Frontend (for build)
VITE_API_URL=/api/v1 VITE_API_URL=/api/v1
RATE_LIMIT_ENABLED=false

View File

@@ -22,353 +22,7 @@ Success: #22c55e
Error: #ef4444 Error: #ef4444
Text: #e2e8f0 Text: #e2e8f0
Text Muted: #64748b 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
---
## Референсы для вдохновления ## Референсы для вдохновления

View File

@@ -9,6 +9,7 @@ from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '001_add_roles' revision: str = '001_add_roles'
@@ -17,17 +18,35 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def constraint_exists(table_name: str, constraint_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
fks = inspector.get_foreign_keys(table_name)
return any(fk['name'] == constraint_name for fk in fks)
def upgrade() -> None: def upgrade() -> None:
# Add role column to users table # Add role column to users table
if not column_exists('users', 'role'):
op.add_column('users', sa.Column('role', sa.String(20), nullable=False, server_default='user')) op.add_column('users', sa.Column('role', sa.String(20), nullable=False, server_default='user'))
# Add role column to participants table # Add role column to participants table
if not column_exists('participants', 'role'):
op.add_column('participants', sa.Column('role', sa.String(20), nullable=False, server_default='participant')) op.add_column('participants', sa.Column('role', sa.String(20), nullable=False, server_default='participant'))
# Rename organizer_id to creator_id in marathons table # Rename organizer_id to creator_id in marathons table
if column_exists('marathons', 'organizer_id') and not column_exists('marathons', 'creator_id'):
op.alter_column('marathons', 'organizer_id', new_column_name='creator_id') op.alter_column('marathons', 'organizer_id', new_column_name='creator_id')
# Update existing participants: set role='organizer' for marathon creators # Update existing participants: set role='organizer' for marathon creators
# This is idempotent - running multiple times is safe
op.execute(""" op.execute("""
UPDATE participants p UPDATE participants p
SET role = 'organizer' SET role = 'organizer'
@@ -36,13 +55,17 @@ def upgrade() -> None:
""") """)
# Add status column to games table # Add status column to games table
if not column_exists('games', 'status'):
op.add_column('games', sa.Column('status', sa.String(20), nullable=False, server_default='approved')) op.add_column('games', sa.Column('status', sa.String(20), nullable=False, server_default='approved'))
# Rename added_by_id to proposed_by_id in games table # Rename added_by_id to proposed_by_id in games table
if column_exists('games', 'added_by_id') and not column_exists('games', 'proposed_by_id'):
op.alter_column('games', 'added_by_id', new_column_name='proposed_by_id') op.alter_column('games', 'added_by_id', new_column_name='proposed_by_id')
# Add approved_by_id column to games table # Add approved_by_id column to games table
if not column_exists('games', 'approved_by_id'):
op.add_column('games', sa.Column('approved_by_id', sa.Integer(), nullable=True)) op.add_column('games', sa.Column('approved_by_id', sa.Integer(), nullable=True))
if not constraint_exists('games', 'fk_games_approved_by_id'):
op.create_foreign_key( op.create_foreign_key(
'fk_games_approved_by_id', 'fk_games_approved_by_id',
'games', 'users', 'games', 'users',
@@ -53,20 +76,27 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
# Remove approved_by_id from games # Remove approved_by_id from games
if constraint_exists('games', 'fk_games_approved_by_id'):
op.drop_constraint('fk_games_approved_by_id', 'games', type_='foreignkey') op.drop_constraint('fk_games_approved_by_id', 'games', type_='foreignkey')
if column_exists('games', 'approved_by_id'):
op.drop_column('games', 'approved_by_id') op.drop_column('games', 'approved_by_id')
# Rename proposed_by_id back to added_by_id # Rename proposed_by_id back to added_by_id
if column_exists('games', 'proposed_by_id'):
op.alter_column('games', 'proposed_by_id', new_column_name='added_by_id') op.alter_column('games', 'proposed_by_id', new_column_name='added_by_id')
# Remove status from games # Remove status from games
if column_exists('games', 'status'):
op.drop_column('games', 'status') op.drop_column('games', 'status')
# Rename creator_id back to organizer_id # Rename creator_id back to organizer_id
if column_exists('marathons', 'creator_id'):
op.alter_column('marathons', 'creator_id', new_column_name='organizer_id') op.alter_column('marathons', 'creator_id', new_column_name='organizer_id')
# Remove role from participants # Remove role from participants
if column_exists('participants', 'role'):
op.drop_column('participants', 'role') op.drop_column('participants', 'role')
# Remove role from users # Remove role from users
if column_exists('users', 'role'):
op.drop_column('users', 'role') op.drop_column('users', 'role')

View File

@@ -9,6 +9,7 @@ from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '002_marathon_settings' revision: str = '002_marathon_settings'
@@ -17,16 +18,27 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None: def upgrade() -> None:
# Add is_public column to marathons table (default False = private) # Add is_public column to marathons table (default False = private)
if not column_exists('marathons', 'is_public'):
op.add_column('marathons', sa.Column('is_public', sa.Boolean(), nullable=False, server_default='false')) op.add_column('marathons', sa.Column('is_public', sa.Boolean(), nullable=False, server_default='false'))
# Add game_proposal_mode column to marathons table # Add game_proposal_mode column to marathons table
# 'all_participants' - anyone can propose games (with moderation) # 'all_participants' - anyone can propose games (with moderation)
# 'organizer_only' - only organizers can add games # 'organizer_only' - only organizers can add games
if not column_exists('marathons', 'game_proposal_mode'):
op.add_column('marathons', sa.Column('game_proposal_mode', sa.String(20), nullable=False, server_default='all_participants')) op.add_column('marathons', sa.Column('game_proposal_mode', sa.String(20), nullable=False, server_default='all_participants'))
def downgrade() -> None: def downgrade() -> None:
if column_exists('marathons', 'game_proposal_mode'):
op.drop_column('marathons', 'game_proposal_mode') op.drop_column('marathons', 'game_proposal_mode')
if column_exists('marathons', 'is_public'):
op.drop_column('marathons', 'is_public') op.drop_column('marathons', 'is_public')

View File

@@ -26,8 +26,8 @@ def upgrade() -> None:
# Insert admin user (ignore if already exists) # Insert admin user (ignore if already exists)
op.execute(f""" op.execute(f"""
INSERT INTO users (login, password_hash, nickname, role, created_at) INSERT INTO users (login, password_hash, nickname, role, is_banned, created_at)
VALUES ('admin', '{password_hash}', 'Admin', 'admin', NOW()) VALUES ('admin', '{password_hash}', 'Admin', 'admin', false, NOW())
ON CONFLICT (login) DO UPDATE SET ON CONFLICT (login) DO UPDATE SET
password_hash = '{password_hash}', password_hash = '{password_hash}',
role = 'admin' role = 'admin'

View File

@@ -17,15 +17,17 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
# Update event type from 'rematch' to 'game_choice' in events table # Update event type from 'rematch' to 'game_choice' in events table
# These UPDATE statements are idempotent - safe to run multiple times
op.execute("UPDATE events SET type = 'game_choice' WHERE type = 'rematch'") op.execute("UPDATE events SET type = 'game_choice' WHERE type = 'rematch'")
# Update event_type in assignments table # Update event_type in assignments table
op.execute("UPDATE assignments SET event_type = 'game_choice' WHERE event_type = 'rematch'") op.execute("UPDATE assignments SET event_type = 'game_choice' WHERE event_type = 'rematch'")
# Update activity data that references rematch event # Update activity data that references rematch event
# Cast JSON to JSONB, apply jsonb_set, then cast back to JSON
op.execute(""" op.execute("""
UPDATE activities UPDATE activities
SET data = jsonb_set(data, '{event_type}', '"game_choice"') SET data = jsonb_set(data::jsonb, '{event_type}', '"game_choice"')::json
WHERE data->>'event_type' = 'rematch' WHERE data->>'event_type' = 'rematch'
""") """)
@@ -36,6 +38,6 @@ def downgrade() -> None:
op.execute("UPDATE assignments SET event_type = 'rematch' WHERE event_type = 'game_choice'") op.execute("UPDATE assignments SET event_type = 'rematch' WHERE event_type = 'game_choice'")
op.execute(""" op.execute("""
UPDATE activities UPDATE activities
SET data = jsonb_set(data, '{event_type}', '"rematch"') SET data = jsonb_set(data::jsonb, '{event_type}', '"rematch"')::json
WHERE data->>'event_type' = 'game_choice' WHERE data->>'event_type' = 'game_choice'
""") """)

View File

@@ -9,6 +9,7 @@ from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
@@ -18,13 +19,26 @@ branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None: def upgrade() -> None:
if not column_exists('users', 'telegram_first_name'):
op.add_column('users', sa.Column('telegram_first_name', sa.String(100), nullable=True)) op.add_column('users', sa.Column('telegram_first_name', sa.String(100), nullable=True))
if not column_exists('users', 'telegram_last_name'):
op.add_column('users', sa.Column('telegram_last_name', sa.String(100), nullable=True)) op.add_column('users', sa.Column('telegram_last_name', sa.String(100), nullable=True))
if not column_exists('users', 'telegram_avatar_url'):
op.add_column('users', sa.Column('telegram_avatar_url', sa.String(500), nullable=True)) op.add_column('users', sa.Column('telegram_avatar_url', sa.String(500), nullable=True))
def downgrade() -> None: def downgrade() -> None:
if column_exists('users', 'telegram_avatar_url'):
op.drop_column('users', 'telegram_avatar_url') op.drop_column('users', 'telegram_avatar_url')
if column_exists('users', 'telegram_last_name'):
op.drop_column('users', 'telegram_last_name') op.drop_column('users', 'telegram_last_name')
if column_exists('users', 'telegram_first_name'):
op.drop_column('users', 'telegram_first_name') op.drop_column('users', 'telegram_first_name')

View File

@@ -0,0 +1,40 @@
"""Add challenge proposals support
Revision ID: 011_add_challenge_proposals
Revises: 010_add_telegram_profile
Create Date: 2024-12-18
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '011_add_challenge_proposals'
down_revision: Union[str, None] = '010_add_telegram_profile'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
if not column_exists('challenges', 'proposed_by_id'):
op.add_column('challenges', sa.Column('proposed_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True))
if not column_exists('challenges', 'status'):
op.add_column('challenges', sa.Column('status', sa.String(20), server_default='approved', nullable=False))
def downgrade() -> None:
if column_exists('challenges', 'status'):
op.drop_column('challenges', 'status')
if column_exists('challenges', 'proposed_by_id'):
op.drop_column('challenges', 'proposed_by_id')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
"""Make admin_id nullable in admin_logs for system actions
Revision ID: 017_admin_logs_nullable_admin_id
Revises: 016_add_banned_until
Create Date: 2024-12-19
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '017_admin_logs_nullable_admin_id'
down_revision: Union[str, None] = '016_add_banned_until'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def is_column_nullable(table_name: str, column_name: str) -> bool:
"""Check if a column is nullable."""
bind = op.get_bind()
inspector = inspect(bind)
columns = inspector.get_columns(table_name)
for col in columns:
if col['name'] == column_name:
return col.get('nullable', True)
return True
def upgrade() -> None:
# Make admin_id nullable for system actions (like auto-unban)
# Only alter if currently not nullable
if not is_column_nullable('admin_logs', 'admin_id'):
op.alter_column('admin_logs', 'admin_id',
existing_type=sa.Integer(),
nullable=True)
def downgrade() -> None:
# Revert to not nullable (will fail if there are NULL values)
if is_column_nullable('admin_logs', 'admin_id'):
op.alter_column('admin_logs', 'admin_id',
existing_type=sa.Integer(),
nullable=False)

View File

@@ -0,0 +1,346 @@
"""Seed static content
Revision ID: 018_seed_static_content
Revises: 017_admin_logs_nullable_admin_id
Create Date: 2024-12-20
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '018_seed_static_content'
down_revision: Union[str, None] = '017_admin_logs_nullable_admin_id'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
STATIC_CONTENT_DATA = [
{
'key': 'terms_of_service',
'title': 'Пользовательское соглашение',
'content': '''<p class="text-gray-400 mb-6">Настоящее Пользовательское соглашение (далее — «Соглашение») регулирует отношения между администрацией интернет-сервиса «Игровой Марафон» (далее — «Сервис», «Платформа», «Мы») и физическим лицом, использующим Сервис (далее — «Пользователь», «Вы»).</p>
<p class="text-gray-400 mb-6"><strong class="text-white">Дата вступления в силу:</strong> с момента регистрации на Платформе.<br/>
Используя Сервис, Вы подтверждаете, что полностью ознакомились с условиями настоящего Соглашения и принимаете их в полном объёме.</p>
<hr class="my-8 border-dark-600" />
<h2>1. Общие положения</h2>
<p>1.1. Сервис «Игровой Марафон» представляет собой онлайн-платформу для организации и проведения игровых марафонов — соревнований, в рамках которых участники выполняют игровые задания (челленджи) и получают очки за их успешное выполнение.</p>
<p>1.2. Сервис предоставляет Пользователям следующие возможности:</p>
<ul>
<li>Создание и участие в игровых марафонах</li>
<li>Получение случайных игровых заданий различной сложности</li>
<li>Отслеживание прогресса и статистики участников</li>
<li>Участие в специальных игровых событиях</li>
<li>Получение уведомлений через интеграцию с Telegram</li>
</ul>
<p>1.3. Сервис предоставляется на условиях «как есть» (as is). Администрация не гарантирует, что Сервис будет соответствовать ожиданиям Пользователя или работать бесперебойно.</p>
<hr class="my-8 border-dark-600" />
<h2>2. Регистрация и учётная запись</h2>
<p>2.1. Для доступа к функционалу Сервиса необходима регистрация учётной записи. При регистрации Пользователь обязуется предоставить достоверные данные.</p>
<p>2.2. Пользователь несёт полную ответственность за:</p>
<ul>
<li>Сохранность своих учётных данных (логина и пароля)</li>
<li>Все действия, совершённые с использованием его учётной записи</li>
<li>Своевременное уведомление Администрации о несанкционированном доступе к аккаунту</li>
</ul>
<p>2.3. Каждый Пользователь имеет право на одну учётную запись. Создание дополнительных аккаунтов (мультиаккаунтинг) запрещено и влечёт блокировку всех связанных учётных записей.</p>
<p>2.4. Пользователь вправе в любой момент удалить свою учётную запись, обратившись к Администрации. При удалении аккаунта все связанные данные будут безвозвратно удалены.</p>
<hr class="my-8 border-dark-600" />
<h2>3. Правила использования Сервиса</h2>
<p>3.1. <strong class="text-white">При использовании Сервиса запрещается:</strong></p>
<ul>
<li>Использовать читы, эксплойты, модификации и любое стороннее программное обеспечение, дающее нечестное преимущество при выполнении игровых заданий</li>
<li>Предоставлять ложные доказательства выполнения заданий (поддельные скриншоты, видео, достижения)</li>
<li>Передавать доступ к учётной записи третьим лицам</li>
<li>Оскорблять, унижать или преследовать других участников</li>
<li>Распространять спам, рекламу или вредоносный контент</li>
<li>Нарушать работу Сервиса техническими средствами</li>
<li>Использовать Сервис для деятельности, нарушающей законодательство</li>
</ul>
<p>3.2. <strong class="text-white">Правила проведения марафонов:</strong></p>
<ul>
<li>Участники обязаны честно выполнять полученные задания</li>
<li>Доказательства выполнения должны быть подлинными и соответствовать требованиям задания</li>
<li>Отказ от задания (дроп) влечёт штрафные санкции согласно правилам конкретного марафона</li>
<li>Споры по заданиям разрешаются через систему диспутов с участием других участников марафона</li>
</ul>
<p>3.3. Организаторы марафонов несут ответственность за соблюдение правил в рамках своих мероприятий и имеют право устанавливать дополнительные правила, не противоречащие настоящему Соглашению.</p>
<hr class="my-8 border-dark-600" />
<h2>4. Система очков и рейтинг</h2>
<p>4.1. За выполнение заданий Пользователи получают очки, количество которых зависит от сложности задания и активных игровых событий.</p>
<p>4.2. Очки используются исключительно для формирования рейтинга участников в рамках марафонов и не имеют денежного эквивалента.</p>
<p>4.3. Администрация оставляет за собой право корректировать начисленные очки в случае выявления нарушений или технических ошибок.</p>
<hr class="my-8 border-dark-600" />
<h2>5. Ответственность сторон</h2>
<p>5.1. <strong class="text-white">Администрация не несёт ответственности за:</strong></p>
<ul>
<li>Временную недоступность Сервиса по техническим причинам</li>
<li>Потерю данных вследствие технических сбоев</li>
<li>Действия третьих лиц, получивших доступ к учётной записи Пользователя</li>
<li>Контент, размещаемый Пользователями</li>
<li>Качество интернет-соединения Пользователя</li>
</ul>
<p>5.2. Пользователь несёт ответственность за соблюдение условий настоящего Соглашения и применимого законодательства.</p>
<hr class="my-8 border-dark-600" />
<h2>6. Санкции за нарушения</h2>
<p>6.1. За нарушение условий настоящего Соглашения Администрация вправе применить следующие санкции:</p>
<ul>
<li><strong class="text-yellow-400">Предупреждение</strong> — за незначительные нарушения</li>
<li><strong class="text-orange-400">Временная блокировка</strong> — ограничение доступа к Сервису на определённый срок</li>
<li><strong class="text-red-400">Постоянная блокировка</strong> — бессрочное ограничение доступа за грубые или повторные нарушения</li>
</ul>
<p>6.2. Решение о применении санкций принимается Администрацией единолично и является окончательным. Администрация не обязана объяснять причины принятого решения.</p>
<p>6.3. Обход блокировки путём создания новых учётных записей влечёт блокировку всех выявленных аккаунтов.</p>
<hr class="my-8 border-dark-600" />
<h2>7. Интеллектуальная собственность</h2>
<p>7.1. Все элементы Сервиса (дизайн, код, тексты, логотипы) являются объектами интеллектуальной собственности Администрации и защищены применимым законодательством.</p>
<p>7.2. Использование материалов Сервиса без письменного разрешения Администрации запрещено.</p>
<hr class="my-8 border-dark-600" />
<h2>8. Изменение условий Соглашения</h2>
<p>8.1. Администрация вправе в одностороннем порядке изменять условия настоящего Соглашения.</p>
<p>8.2. Актуальная редакция Соглашения размещается на данной странице с указанием даты последнего обновления.</p>
<p>8.3. Продолжение использования Сервиса после внесения изменений означает согласие Пользователя с новой редакцией Соглашения.</p>
<hr class="my-8 border-dark-600" />
<h2>9. Заключительные положения</h2>
<p>9.1. Настоящее Соглашение регулируется законодательством Российской Федерации.</p>
<p>9.2. Все споры, возникающие в связи с использованием Сервиса, подлежат разрешению путём переговоров. При недостижении согласия споры разрешаются в судебном порядке по месту нахождения Администрации.</p>
<p>9.3. Признание судом недействительности какого-либо положения настоящего Соглашения не влечёт недействительности остальных положений.</p>
<p>9.4. По всем вопросам, связанным с использованием Сервиса, Вы можете обратиться к Администрации через Telegram-бота или иные доступные каналы связи.</p>'''
},
{
'key': 'privacy_policy',
'title': 'Политика конфиденциальности',
'content': '''<p class="text-gray-400 mb-6">Настоящая Политика конфиденциальности (далее — «Политика») описывает, как интернет-сервис «Игровой Марафон» (далее — «Сервис», «Мы») собирает, использует, хранит и защищает персональные данные пользователей (далее — «Пользователь», «Вы»).</p>
<p class="text-gray-400 mb-6">Используя Сервис, Вы даёте согласие на обработку Ваших персональных данных в соответствии с условиями настоящей Политики.</p>
<hr class="my-8 border-dark-600" />
<h2>1. Собираемые данные</h2>
<p>1.1. <strong class="text-white">Данные, предоставляемые Пользователем:</strong></p>
<ul>
<li><strong>Регистрационные данные:</strong> логин, пароль (в зашифрованном виде), никнейм</li>
<li><strong>Данные профиля:</strong> аватар (при загрузке)</li>
<li><strong>Данные интеграции Telegram:</strong> Telegram ID, имя пользователя, имя и фамилия (при привязке бота)</li>
</ul>
<p>1.2. <strong class="text-white">Данные, собираемые автоматически:</strong></p>
<ul>
<li><strong>Данные об активности:</strong> участие в марафонах, выполненные задания, заработанные очки, статистика</li>
<li><strong>Технические данные:</strong> IP-адрес, тип браузера, время доступа (для обеспечения безопасности)</li>
<li><strong>Данные сессии:</strong> информация для поддержания авторизации</li>
</ul>
<hr class="my-8 border-dark-600" />
<h2>2. Цели обработки данных</h2>
<p>2.1. Мы обрабатываем Ваши персональные данные для следующих целей:</p>
<p><strong class="text-neon-400">Предоставление услуг:</strong></p>
<ul>
<li>Идентификация и аутентификация Пользователя</li>
<li>Обеспечение участия в марафонах и игровых событиях</li>
<li>Ведение статистики и формирование рейтингов</li>
<li>Отображение профиля Пользователя другим участникам</li>
</ul>
<p><strong class="text-neon-400">Коммуникация:</strong></p>
<ul>
<li>Отправка уведомлений о событиях марафонов через Telegram-бота</li>
<li>Информирование о новых заданиях и результатах</li>
<li>Ответы на обращения Пользователей</li>
</ul>
<p><strong class="text-neon-400">Безопасность:</strong></p>
<ul>
<li>Защита от несанкционированного доступа</li>
<li>Выявление и предотвращение нарушений</li>
<li>Ведение журнала административных действий</li>
</ul>
<hr class="my-8 border-dark-600" />
<h2>3. Правовые основания обработки</h2>
<p>3.1. Обработка персональных данных осуществляется на следующих основаниях:</p>
<ul>
<li><strong>Согласие Пользователя</strong> — при регистрации и использовании Сервиса</li>
<li><strong>Исполнение договора</strong> — Пользовательского соглашения</li>
<li><strong>Законный интерес</strong> — обеспечение безопасности Сервиса</li>
</ul>
<hr class="my-8 border-dark-600" />
<h2>4. Хранение и защита данных</h2>
<p>4.1. <strong class="text-white">Меры безопасности:</strong></p>
<ul>
<li>Пароли хранятся в зашифрованном виде с использованием алгоритма bcrypt</li>
<li>Передача данных осуществляется по защищённому протоколу HTTPS</li>
<li>Доступ к базе данных ограничен и контролируется</li>
<li>Административные действия логируются и требуют двухфакторной аутентификации</li>
</ul>
<p>4.2. <strong class="text-white">Срок хранения:</strong></p>
<ul>
<li>Данные учётной записи хранятся до момента её удаления Пользователем</li>
<li>Данные об активности в марафонах хранятся бессрочно для ведения статистики</li>
<li>Технические логи хранятся в течение 12 месяцев</li>
</ul>
<hr class="my-8 border-dark-600" />
<h2>5. Передача данных третьим лицам</h2>
<p>5.1. Мы не продаём, не сдаём в аренду и не передаём Ваши персональные данные третьим лицам в коммерческих целях.</p>
<p>5.2. <strong class="text-white">Данные могут быть переданы:</strong></p>
<ul>
<li>Telegram — для обеспечения работы уведомлений (только Telegram ID)</li>
<li>Правоохранительным органам — по законному запросу в соответствии с применимым законодательством</li>
</ul>
<p>5.3. <strong class="text-white">Публично доступная информация:</strong></p>
<p>Следующие данные видны другим Пользователям Сервиса:</p>
<ul>
<li>Никнейм</li>
<li>Аватар</li>
<li>Статистика участия в марафонах</li>
<li>Позиция в рейтингах</li>
</ul>
<hr class="my-8 border-dark-600" />
<h2>6. Права Пользователя</h2>
<p>6.1. Вы имеете право:</p>
<ul>
<li><strong>Получить доступ</strong> к своим персональным данным</li>
<li><strong>Исправить</strong> неточные или неполные данные в настройках профиля</li>
<li><strong>Удалить</strong> свою учётную запись и связанные данные</li>
<li><strong>Отозвать согласие</strong> на обработку данных (путём удаления аккаунта)</li>
<li><strong>Отключить</strong> интеграцию с Telegram в любой момент</li>
</ul>
<p>6.2. Для реализации своих прав обратитесь к Администрации через доступные каналы связи.</p>
<hr class="my-8 border-dark-600" />
<h2>7. Файлы cookie и локальное хранилище</h2>
<p>7.1. Сервис использует локальное хранилище браузера (localStorage, sessionStorage) для:</p>
<ul>
<li>Хранения токена авторизации</li>
<li>Сохранения пользовательских настроек интерфейса</li>
<li>Запоминания закрытых информационных баннеров</li>
</ul>
<p>7.2. Вы можете очистить локальное хранилище в настройках браузера, однако это приведёт к выходу из учётной записи.</p>
<hr class="my-8 border-dark-600" />
<h2>8. Обработка данных несовершеннолетних</h2>
<p>8.1. Сервис не предназначен для лиц младше 14 лет. Мы сознательно не собираем персональные данные детей.</p>
<p>8.2. Если Вам стало известно, что ребёнок предоставил нам персональные данные, пожалуйста, свяжитесь с Администрацией для их удаления.</p>
<hr class="my-8 border-dark-600" />
<h2>9. Изменение Политики</h2>
<p>9.1. Мы оставляем за собой право изменять настоящую Политику. Актуальная редакция всегда доступна на данной странице.</p>
<p>9.2. О существенных изменениях мы уведомим Пользователей через Telegram-бота или баннер на сайте.</p>
<p>9.3. Продолжение использования Сервиса после внесения изменений означает согласие с обновлённой Политикой.</p>
<hr class="my-8 border-dark-600" />
<h2>10. Контактная информация</h2>
<p>10.1. По вопросам, связанным с обработкой персональных данных, Вы можете обратиться к Администрации через:</p>
<ul>
<li>Telegram-бота Сервиса</li>
<li>Форму обратной связи (при наличии)</li>
</ul>
<p>10.2. Мы обязуемся рассмотреть Ваше обращение в разумные сроки и предоставить ответ.</p>'''
},
{
'key': 'telegram_bot_info',
'title': 'Привяжите Telegram-бота',
'content': 'Получайте уведомления о событиях марафона, новых заданиях и результатах прямо в Telegram'
},
{
'key': 'announcement',
'title': 'Добро пожаловать!',
'content': 'Мы рады приветствовать вас в «Игровом Марафоне»! Создайте свой первый марафон или присоединитесь к существующему по коду приглашения.'
}
]
def upgrade() -> None:
for item in STATIC_CONTENT_DATA:
# Use ON CONFLICT to avoid duplicates
op.execute(f"""
INSERT INTO static_content (key, title, content, created_at, updated_at)
VALUES ('{item['key']}', '{item['title'].replace("'", "''")}', '{item['content'].replace("'", "''")}', NOW(), NOW())
ON CONFLICT (key) DO NOTHING
""")
def downgrade() -> None:
keys = [f"'{item['key']}'" for item in STATIC_CONTENT_DATA]
op.execute(f"DELETE FROM static_content WHERE key IN ({', '.join(keys)})")

View File

@@ -0,0 +1,43 @@
"""Add marathon cover fields (cover_path and cover_url)
Revision ID: 019_add_marathon_cover
Revises: 018_seed_static_content
Create Date: 2024-12-21
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '019_add_marathon_cover'
down_revision: Union[str, None] = '018_seed_static_content'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
# cover_path - путь к файлу в S3 хранилище
if not column_exists('marathons', 'cover_path'):
op.add_column('marathons', sa.Column('cover_path', sa.String(500), nullable=True))
# cover_url - API URL для доступа к обложке
if not column_exists('marathons', 'cover_url'):
op.add_column('marathons', sa.Column('cover_url', sa.String(500), nullable=True))
def downgrade() -> None:
if column_exists('marathons', 'cover_url'):
op.drop_column('marathons', 'cover_url')
if column_exists('marathons', 'cover_path'):
op.drop_column('marathons', 'cover_path')

View File

@@ -0,0 +1,156 @@
"""Add game types (playthrough/challenges) and bonus assignments
Revision ID: 020_add_game_types
Revises: 019_add_marathon_cover
Create Date: 2024-12-26
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '020_add_game_types'
down_revision: Union[str, None] = '019_add_marathon_cover'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def upgrade() -> None:
# === Games table: добавляем поля для типа игры ===
# game_type - тип игры (playthrough/challenges)
if not column_exists('games', 'game_type'):
op.add_column('games', sa.Column(
'game_type',
sa.String(20),
nullable=False,
server_default='challenges'
))
# playthrough_points - очки за прохождение
if not column_exists('games', 'playthrough_points'):
op.add_column('games', sa.Column(
'playthrough_points',
sa.Integer(),
nullable=True
))
# playthrough_description - описание прохождения
if not column_exists('games', 'playthrough_description'):
op.add_column('games', sa.Column(
'playthrough_description',
sa.Text(),
nullable=True
))
# playthrough_proof_type - тип пруфа для прохождения
if not column_exists('games', 'playthrough_proof_type'):
op.add_column('games', sa.Column(
'playthrough_proof_type',
sa.String(20),
nullable=True
))
# playthrough_proof_hint - подсказка для пруфа
if not column_exists('games', 'playthrough_proof_hint'):
op.add_column('games', sa.Column(
'playthrough_proof_hint',
sa.Text(),
nullable=True
))
# === Assignments table: добавляем поля для прохождений ===
# game_id - ссылка на игру (для playthrough)
if not column_exists('assignments', 'game_id'):
op.add_column('assignments', sa.Column(
'game_id',
sa.Integer(),
sa.ForeignKey('games.id', ondelete='CASCADE'),
nullable=True
))
op.create_index('ix_assignments_game_id', 'assignments', ['game_id'])
# is_playthrough - флаг прохождения
if not column_exists('assignments', 'is_playthrough'):
op.add_column('assignments', sa.Column(
'is_playthrough',
sa.Boolean(),
nullable=False,
server_default='false'
))
# Делаем challenge_id nullable (для playthrough заданий)
# SQLite не поддерживает ALTER COLUMN, поэтому проверяем dialect
bind = op.get_bind()
if bind.dialect.name != 'sqlite':
op.alter_column('assignments', 'challenge_id', nullable=True)
# === Создаём таблицу bonus_assignments ===
if not table_exists('bonus_assignments'):
op.create_table(
'bonus_assignments',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('main_assignment_id', sa.Integer(),
sa.ForeignKey('assignments.id', ondelete='CASCADE'),
nullable=False, index=True),
sa.Column('challenge_id', sa.Integer(),
sa.ForeignKey('challenges.id', ondelete='CASCADE'),
nullable=False, index=True),
sa.Column('status', sa.String(20), nullable=False, server_default='pending'),
sa.Column('proof_path', sa.String(500), nullable=True),
sa.Column('proof_url', sa.Text(), nullable=True),
sa.Column('proof_comment', sa.Text(), nullable=True),
sa.Column('points_earned', sa.Integer(), nullable=False, server_default='0'),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False,
server_default=sa.func.now()),
)
def downgrade() -> None:
# Удаляем таблицу bonus_assignments
if table_exists('bonus_assignments'):
op.drop_table('bonus_assignments')
# Удаляем поля из assignments
if column_exists('assignments', 'is_playthrough'):
op.drop_column('assignments', 'is_playthrough')
if column_exists('assignments', 'game_id'):
op.drop_index('ix_assignments_game_id', 'assignments')
op.drop_column('assignments', 'game_id')
# Удаляем поля из games
if column_exists('games', 'playthrough_proof_hint'):
op.drop_column('games', 'playthrough_proof_hint')
if column_exists('games', 'playthrough_proof_type'):
op.drop_column('games', 'playthrough_proof_type')
if column_exists('games', 'playthrough_description'):
op.drop_column('games', 'playthrough_description')
if column_exists('games', 'playthrough_points'):
op.drop_column('games', 'playthrough_points')
if column_exists('games', 'game_type'):
op.drop_column('games', 'game_type')

View File

@@ -0,0 +1,100 @@
"""Add bonus assignment disputes support
Revision ID: 021_add_bonus_disputes
Revises: 020_add_game_types
Create Date: 2024-12-29
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '021_add_bonus_disputes'
down_revision: Union[str, None] = '020_add_game_types'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns
def constraint_exists(table_name: str, constraint_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
constraints = inspector.get_unique_constraints(table_name)
return any(c['name'] == constraint_name for c in constraints)
def upgrade() -> None:
bind = op.get_bind()
# Add bonus_assignment_id column to disputes
if not column_exists('disputes', 'bonus_assignment_id'):
op.add_column('disputes', sa.Column(
'bonus_assignment_id',
sa.Integer(),
nullable=True
))
op.create_foreign_key(
'fk_disputes_bonus_assignment_id',
'disputes',
'bonus_assignments',
['bonus_assignment_id'],
['id'],
ondelete='CASCADE'
)
op.create_index('ix_disputes_bonus_assignment_id', 'disputes', ['bonus_assignment_id'])
# Drop the unique index on assignment_id first (required before making nullable)
if bind.dialect.name != 'sqlite':
try:
op.drop_index('ix_disputes_assignment_id', 'disputes')
except Exception:
pass # Index might not exist
# Make assignment_id nullable (PostgreSQL only, SQLite doesn't support ALTER COLUMN)
if bind.dialect.name != 'sqlite':
op.alter_column('disputes', 'assignment_id', nullable=True)
# Create a non-unique index on assignment_id
try:
op.create_index('ix_disputes_assignment_id_non_unique', 'disputes', ['assignment_id'])
except Exception:
pass # Index might already exist
def downgrade() -> None:
bind = op.get_bind()
# Remove non-unique index
try:
op.drop_index('ix_disputes_assignment_id_non_unique', table_name='disputes')
except Exception:
pass
# Make assignment_id not nullable again
if bind.dialect.name != 'sqlite':
op.alter_column('disputes', 'assignment_id', nullable=False)
# Recreate unique index
try:
op.create_index('ix_disputes_assignment_id', 'disputes', ['assignment_id'], unique=True)
except Exception:
pass
# Remove foreign key, index and column
if column_exists('disputes', 'bonus_assignment_id'):
try:
op.drop_constraint('fk_disputes_bonus_assignment_id', 'disputes', type_='foreignkey')
except Exception:
pass
op.drop_index('ix_disputes_bonus_assignment_id', table_name='disputes')
op.drop_column('disputes', 'bonus_assignment_id')

View File

@@ -1,4 +1,5 @@
from typing import Annotated from typing import Annotated
from datetime import datetime
from fastapi import Depends, HTTPException, status, Header from fastapi import Depends, HTTPException, status, Header
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
@@ -8,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
from app.core.database import get_db from app.core.database import get_db
from app.core.security import decode_access_token from app.core.security import decode_access_token
from app.models import User, Participant, Marathon, UserRole, ParticipantRole from app.models import User, Participant, Marathon, UserRole, ParticipantRole, AdminLog, AdminActionType
security = HTTPBearer() security = HTTPBearer()
@@ -43,6 +44,50 @@ async def get_current_user(
detail="User not found", detail="User not found",
) )
# Check if user is banned
if user.is_banned:
# Auto-unban if ban expired
if user.banned_until and datetime.utcnow() > user.banned_until:
# Save ban info for logging before clearing
old_ban_reason = user.ban_reason
old_banned_until = user.banned_until.isoformat() if user.banned_until else None
user.is_banned = False
user.banned_at = None
user.banned_until = None
user.banned_by_id = None
user.ban_reason = None
# Log system auto-unban action
log = AdminLog(
admin_id=None, # System action, no admin
action=AdminActionType.USER_AUTO_UNBAN.value,
target_type="user",
target_id=user.id,
details={
"nickname": user.nickname,
"reason": old_ban_reason,
"banned_until": old_banned_until,
"system": True,
},
ip_address=None,
)
db.add(log)
await db.commit()
await db.refresh(user)
else:
# Still banned - return ban info in error
ban_info = {
"banned_at": user.banned_at.isoformat() if user.banned_at else None,
"banned_until": user.banned_until.isoformat() if user.banned_until else None,
"reason": user.ban_reason,
}
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ban_info,
)
return user return user
@@ -56,6 +101,21 @@ def require_admin(user: User) -> User:
return user return user
def require_admin_with_2fa(user: User) -> User:
"""Check if user is admin with Telegram linked (2FA enabled)"""
if not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
if not user.telegram_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Для доступа к админ-панели необходимо привязать Telegram в профиле",
)
return user
async def get_participant( async def get_participant(
db: AsyncSession, db: AsyncSession,
user_id: int, user_id: int,

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,15 @@
from datetime import datetime, timedelta
import secrets
from fastapi import APIRouter, HTTPException, status, Request 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.core.rate_limit import limiter
from app.models import User from app.models import User, UserRole, Admin2FASession
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate, LoginResponse
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@@ -40,7 +44,7 @@ async def register(request: Request, data: UserRegister, db: DbSession):
) )
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=LoginResponse)
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def login(request: Request, data: UserLogin, db: DbSession): async def login(request: Request, data: UserLogin, db: DbSession):
# Find user # Find user
@@ -53,6 +57,105 @@ async def login(request: Request, data: UserLogin, db: DbSession):
detail="Incorrect login or password", detail="Incorrect login or password",
) )
# Check if user is banned
if user.is_banned:
# Return full ban info like in deps.py
ban_info = {
"banned_at": user.banned_at.isoformat() if user.banned_at else None,
"banned_until": user.banned_until.isoformat() if user.banned_until else None,
"reason": user.ban_reason,
}
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ban_info,
)
# If admin with Telegram linked, require 2FA
if user.role == UserRole.ADMIN.value and user.telegram_id:
# Generate 6-digit code
code = "".join([str(secrets.randbelow(10)) for _ in range(6)])
# Create 2FA session (expires in 5 minutes)
session = Admin2FASession(
user_id=user.id,
code=code,
expires_at=datetime.utcnow() + timedelta(minutes=5),
)
db.add(session)
await db.commit()
await db.refresh(session)
# Send code to Telegram
message = f"🔐 <b>Код подтверждения для входа в админку</b>\n\nВаш код: <code>{code}</code>\n\nКод действителен 5 минут."
sent = await telegram_notifier.send_message(user.telegram_id, message)
if sent:
session.telegram_sent = True
await db.commit()
return LoginResponse(
requires_2fa=True,
two_factor_session_id=session.id,
)
# Regular user or admin without Telegram - generate token immediately
# Admin without Telegram can login but admin panel will check for Telegram
access_token = create_access_token(subject=user.id)
return LoginResponse(
access_token=access_token,
user=UserPrivate.model_validate(user),
)
@router.post("/2fa/verify", response_model=TokenResponse)
@limiter.limit("5/minute")
async def verify_2fa(request: Request, session_id: int, code: str, db: DbSession):
"""Verify 2FA code and return JWT token."""
# Find session
result = await db.execute(
select(Admin2FASession).where(Admin2FASession.id == session_id)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid session",
)
if session.is_verified:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Session already verified",
)
if datetime.utcnow() > session.expires_at:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Code expired",
)
if session.code != code:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid code",
)
# Mark as verified
session.is_verified = True
await db.commit()
# Get user
result = await db.execute(select(User).where(User.id == session.user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User not found",
)
# Generate token # Generate token
access_token = create_access_token(subject=user.id) access_token = create_access_token(subject=user.id)

View File

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

View File

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

View File

@@ -937,6 +937,7 @@ def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
id=game.id, id=game.id,
title=game.title, title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"), cover_url=storage_service.get_url(game.cover_path, "covers"),
download_url=game.download_url,
), ),
is_generated=challenge.is_generated, is_generated=challenge.is_generated,
created_at=challenge.created_at, created_at=challenge.created_at,

View File

@@ -7,8 +7,12 @@ from app.api.deps import (
require_participant, require_organizer, get_participant, require_participant, require_organizer, get_participant,
) )
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, GameType,
Challenge, Activity, ActivityType, Assignment, AssignmentStatus, Participant
)
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
from app.schemas.assignment import AvailableGamesCount
from app.services.storage import storage_service from app.services.storage import storage_service
from app.services.telegram_notifier import telegram_notifier from app.services.telegram_notifier import telegram_notifier
@@ -43,6 +47,12 @@ def game_to_response(game: Game, challenges_count: int = 0) -> GameResponse:
approved_by=UserPublic.model_validate(game.approved_by) if game.approved_by else None, approved_by=UserPublic.model_validate(game.approved_by) if game.approved_by else None,
challenges_count=challenges_count, challenges_count=challenges_count,
created_at=game.created_at, created_at=game.created_at,
# Поля для типа игры
game_type=game.game_type,
playthrough_points=game.playthrough_points,
playthrough_description=game.playthrough_description,
playthrough_proof_type=game.playthrough_proof_type,
playthrough_proof_hint=game.playthrough_proof_hint,
) )
@@ -145,6 +155,12 @@ async def add_game(
proposed_by_id=current_user.id, proposed_by_id=current_user.id,
status=game_status, status=game_status,
approved_by_id=current_user.id if is_organizer else None, approved_by_id=current_user.id if is_organizer else None,
# Поля для типа игры
game_type=data.game_type.value,
playthrough_points=data.playthrough_points,
playthrough_description=data.playthrough_description,
playthrough_proof_type=data.playthrough_proof_type.value if data.playthrough_proof_type else None,
playthrough_proof_hint=data.playthrough_proof_hint,
) )
db.add(game) db.add(game)
@@ -171,6 +187,12 @@ async def add_game(
approved_by=UserPublic.model_validate(current_user) if is_organizer else None, approved_by=UserPublic.model_validate(current_user) if is_organizer else None,
challenges_count=0, challenges_count=0,
created_at=game.created_at, created_at=game.created_at,
# Поля для типа игры
game_type=game.game_type,
playthrough_points=game.playthrough_points,
playthrough_description=game.playthrough_description,
playthrough_proof_type=game.playthrough_proof_type,
playthrough_proof_hint=game.playthrough_proof_hint,
) )
@@ -227,6 +249,18 @@ async def update_game(
if data.genre is not None: if data.genre is not None:
game.genre = data.genre game.genre = data.genre
# Поля для типа игры
if data.game_type is not None:
game.game_type = data.game_type.value
if data.playthrough_points is not None:
game.playthrough_points = data.playthrough_points
if data.playthrough_description is not None:
game.playthrough_description = data.playthrough_description
if data.playthrough_proof_type is not None:
game.playthrough_proof_type = data.playthrough_proof_type.value
if data.playthrough_proof_hint is not None:
game.playthrough_proof_hint = data.playthrough_proof_hint
await db.commit() await db.commit()
return await get_game(game_id, current_user, db) return await get_game(game_id, current_user, db)
@@ -398,3 +432,159 @@ async def upload_cover(
await db.commit() await db.commit()
return await get_game(game_id, current_user, db) return await get_game(game_id, current_user, db)
async def get_available_games_for_participant(
db, participant: Participant, marathon_id: int
) -> tuple[list[Game], int]:
"""
Получить список игр, доступных для спина участника.
Возвращает кортеж (доступные игры, всего игр).
Логика исключения:
- playthrough: игра исключается если участник завершил ИЛИ дропнул прохождение
- challenges: игра исключается если участник выполнил ВСЕ челленджи
"""
from sqlalchemy.orm import selectinload
# Получаем все одобренные игры с челленджами
result = await db.execute(
select(Game)
.options(selectinload(Game.challenges))
.where(
Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value
)
)
all_games = list(result.scalars().all())
# Фильтруем игры с челленджами (для типа challenges)
# или игры с заполненными playthrough полями (для типа playthrough)
games_with_content = []
for game in all_games:
if game.game_type == GameType.PLAYTHROUGH.value:
# Для playthrough не нужны челленджи
if game.playthrough_points and game.playthrough_description:
games_with_content.append(game)
else:
# Для challenges нужны челленджи
if game.challenges:
games_with_content.append(game)
total_games = len(games_with_content)
if total_games == 0:
return [], 0
# Получаем завершённые/дропнутые assignments участника
finished_statuses = [AssignmentStatus.COMPLETED.value, AssignmentStatus.DROPPED.value]
# Для playthrough: получаем game_id завершённых/дропнутых прохождений
playthrough_result = await db.execute(
select(Assignment.game_id)
.where(
Assignment.participant_id == participant.id,
Assignment.is_playthrough == True,
Assignment.status.in_(finished_statuses)
)
)
finished_playthrough_game_ids = set(playthrough_result.scalars().all())
# Для challenges: получаем challenge_id завершённых заданий
challenges_result = await db.execute(
select(Assignment.challenge_id)
.where(
Assignment.participant_id == participant.id,
Assignment.is_playthrough == False,
Assignment.status == AssignmentStatus.COMPLETED.value
)
)
completed_challenge_ids = set(challenges_result.scalars().all())
# Фильтруем доступные игры
available_games = []
for game in games_with_content:
if game.game_type == GameType.PLAYTHROUGH.value:
# Исключаем если игра уже завершена/дропнута
if game.id not in finished_playthrough_game_ids:
available_games.append(game)
else:
# Для challenges: исключаем если все челленджи выполнены
game_challenge_ids = {c.id for c in game.challenges}
if not game_challenge_ids.issubset(completed_challenge_ids):
available_games.append(game)
return available_games, total_games
@router.get("/marathons/{marathon_id}/available-games-count", response_model=AvailableGamesCount)
async def get_available_games_count(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""
Получить количество игр, доступных для спина.
Возвращает { available: X, total: Y } где:
- available: количество игр, которые могут выпасть
- total: общее количество игр в марафоне
"""
participant = await get_participant(db, current_user.id, marathon_id)
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
available_games, total_games = await get_available_games_for_participant(
db, participant, marathon_id
)
return AvailableGamesCount(
available=len(available_games),
total=total_games
)
@router.get("/marathons/{marathon_id}/available-games", response_model=list[GameResponse])
async def get_available_games(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""
Получить список игр, доступных для спина.
Возвращает только те игры, которые могут выпасть участнику:
- Для playthrough: исключаются игры которые уже завершены/дропнуты
- Для challenges: исключаются игры где все челленджи выполнены
"""
participant = await get_participant(db, current_user.id, marathon_id)
if not participant:
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
available_games, _ = await get_available_games_for_participant(
db, participant, marathon_id
)
# Convert to response with challenges count
result = []
for game in available_games:
challenges_count = len(game.challenges) if game.challenges else 0
result.append(GameResponse(
id=game.id,
title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"),
download_url=game.download_url,
genre=game.genre,
status=game.status,
proposed_by=None,
approved_by=None,
challenges_count=challenges_count,
created_at=game.created_at,
game_type=game.game_type,
playthrough_points=game.playthrough_points,
playthrough_description=game.playthrough_description,
playthrough_proof_type=game.playthrough_proof_type,
playthrough_proof_hint=game.playthrough_proof_hint,
))
return result

View File

@@ -1,7 +1,7 @@
from datetime import timedelta from datetime import timedelta
import secrets import secrets
import string import string
from fastapi import APIRouter, HTTPException, status, Depends from fastapi import APIRouter, HTTPException, status, Depends, UploadFile, File, Response
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials 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
@@ -11,13 +11,16 @@ from app.api.deps import (
require_participant, require_organizer, require_creator, require_participant, require_organizer, require_creator,
get_participant, get_participant,
) )
from app.core.config import settings
from app.core.security import decode_access_token from app.core.security import decode_access_token
from app.services.storage import storage_service
# Optional auth for endpoints that need it conditionally # Optional auth for endpoints that need it conditionally
optional_auth = HTTPBearer(auto_error=False) optional_auth = HTTPBearer(auto_error=False)
from app.models import ( from app.models import (
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge, Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole, Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus,
) )
from app.schemas import ( from app.schemas import (
MarathonCreate, MarathonCreate,
@@ -62,6 +65,7 @@ async def get_marathon_by_code(invite_code: str, db: DbSession):
title=marathon.title, title=marathon.title,
description=marathon.description, description=marathon.description,
status=marathon.status, status=marathon.status,
cover_url=marathon.cover_url,
participants_count=participants_count, participants_count=participants_count,
creator_nickname=marathon.creator.nickname, creator_nickname=marathon.creator.nickname,
) )
@@ -128,6 +132,7 @@ async def list_marathons(current_user: CurrentUser, db: DbSession):
title=marathon.title, title=marathon.title,
status=marathon.status, status=marathon.status,
is_public=marathon.is_public, is_public=marathon.is_public,
cover_url=marathon.cover_url,
participants_count=row[1], participants_count=row[1],
start_date=marathon.start_date, start_date=marathon.start_date,
end_date=marathon.end_date, end_date=marathon.end_date,
@@ -180,6 +185,7 @@ async def create_marathon(
is_public=marathon.is_public, is_public=marathon.is_public,
game_proposal_mode=marathon.game_proposal_mode, game_proposal_mode=marathon.game_proposal_mode,
auto_events_enabled=marathon.auto_events_enabled, auto_events_enabled=marathon.auto_events_enabled,
cover_url=marathon.cover_url,
start_date=marathon.start_date, start_date=marathon.start_date,
end_date=marathon.end_date, end_date=marathon.end_date,
participants_count=1, participants_count=1,
@@ -226,6 +232,7 @@ async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSessio
is_public=marathon.is_public, is_public=marathon.is_public,
game_proposal_mode=marathon.game_proposal_mode, game_proposal_mode=marathon.game_proposal_mode,
auto_events_enabled=marathon.auto_events_enabled, auto_events_enabled=marathon.auto_events_enabled,
cover_url=marathon.cover_url,
start_date=marathon.start_date, start_date=marathon.start_date,
end_date=marathon.end_date, end_date=marathon.end_date,
participants_count=participants_count, participants_count=participants_count,
@@ -301,9 +308,12 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
if len(approved_games) == 0: if len(approved_games) == 0:
raise HTTPException(status_code=400, detail="Добавьте и одобрите хотя бы одну игру") raise HTTPException(status_code=400, detail="Добавьте и одобрите хотя бы одну игру")
# Check that all approved games have at least one challenge # Check that all approved challenge-based games have at least one challenge
# Playthrough games don't need challenges
games_without_challenges = [] games_without_challenges = []
for game in approved_games: for game in approved_games:
if game.is_playthrough:
continue # Игры типа "Прохождение" не требуют челленджей
challenge_count = await db.scalar( challenge_count = await db.scalar(
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id) select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
) )
@@ -591,3 +601,366 @@ async def get_leaderboard(
)) ))
return leaderboard return leaderboard
@router.get("/{marathon_id}/cover")
async def get_marathon_cover(marathon_id: int, db: DbSession):
"""Get marathon cover image"""
marathon = await get_marathon_or_404(db, marathon_id)
if not marathon.cover_path:
raise HTTPException(status_code=404, detail="Marathon has no cover")
file_data = await storage_service.get_file(marathon.cover_path, "covers")
if not file_data:
raise HTTPException(status_code=404, detail="Cover 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.post("/{marathon_id}/cover", response_model=MarathonResponse)
async def upload_marathon_cover(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
file: UploadFile = File(...),
):
"""Upload marathon cover image (organizers only, preparing status)"""
await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot update cover of active or finished marathon")
# Validate file
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be an image",
)
contents = await file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
)
# Get file extension
ext = file.filename.split(".")[-1].lower() if file.filename else "jpg"
if ext not in settings.ALLOWED_IMAGE_EXTENSIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
)
# Delete old cover if exists
if marathon.cover_path:
await storage_service.delete_file(marathon.cover_path)
# Upload file
filename = storage_service.generate_filename(marathon_id, file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="covers",
filename=filename,
content_type=file.content_type or "image/jpeg",
)
# Update marathon with cover path and URL
marathon.cover_path = file_path
marathon.cover_url = f"/api/v1/marathons/{marathon_id}/cover"
await db.commit()
return await get_marathon(marathon_id, current_user, db)
@router.delete("/{marathon_id}/cover", response_model=MarathonResponse)
async def delete_marathon_cover(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
):
"""Delete marathon cover image (organizers only, preparing status)"""
await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot update cover of active or finished marathon")
if not marathon.cover_path:
raise HTTPException(status_code=400, detail="Marathon has no cover")
# Delete file from storage
await storage_service.delete_file(marathon.cover_path)
marathon.cover_path = None
marathon.cover_url = None
await db.commit()
return await get_marathon(marathon_id, current_user, db)
# ============ Marathon Disputes (for organizers) ============
from pydantic import BaseModel, Field
from datetime import datetime
class MarathonDisputeResponse(BaseModel):
id: int
assignment_id: int | None
bonus_assignment_id: int | None
challenge_title: str
participant_nickname: str
raised_by_nickname: str
reason: str
status: str
votes_valid: int
votes_invalid: int
created_at: str
expires_at: str
class Config:
from_attributes = True
class ResolveDisputeRequest(BaseModel):
is_valid: bool = Field(..., description="True = proof is valid, False = proof is invalid")
@router.get("/{marathon_id}/disputes", response_model=list[MarathonDisputeResponse])
async def list_marathon_disputes(
marathon_id: int,
current_user: CurrentUser,
db: DbSession,
status_filter: str = "open",
):
"""List disputes in a marathon. Organizers only."""
await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
from datetime import timedelta
DISPUTE_WINDOW_HOURS = 24
# Get all assignments in this marathon (through games)
games_result = await db.execute(
select(Game.id).where(Game.marathon_id == marathon_id)
)
game_ids = [g[0] for g in games_result.all()]
if not game_ids:
return []
# Get disputes for assignments in these games
# Using selectinload for eager loading - no explicit joins needed
query = (
select(Dispute)
.options(
selectinload(Dispute.raised_by),
selectinload(Dispute.votes),
selectinload(Dispute.assignment).selectinload(Assignment.participant).selectinload(Participant.user),
selectinload(Dispute.assignment).selectinload(Assignment.challenge),
selectinload(Dispute.assignment).selectinload(Assignment.game),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant).selectinload(Participant.user),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
)
.order_by(Dispute.created_at.desc())
)
if status_filter == "open":
query = query.where(Dispute.status == DisputeStatus.OPEN.value)
result = await db.execute(query)
all_disputes = result.scalars().unique().all()
# Filter disputes that belong to this marathon's games
response = []
for dispute in all_disputes:
# Check if dispute belongs to this marathon
if dispute.bonus_assignment_id:
bonus = dispute.bonus_assignment
if not bonus or not bonus.main_assignment:
continue
if bonus.main_assignment.game_id not in game_ids:
continue
participant = bonus.main_assignment.participant
challenge_title = f"Бонус: {bonus.challenge.title}"
else:
assignment = dispute.assignment
if not assignment:
continue
if assignment.is_playthrough:
if assignment.game_id not in game_ids:
continue
challenge_title = f"Прохождение: {assignment.game.title}"
else:
if not assignment.challenge or assignment.challenge.game_id not in game_ids:
continue
challenge_title = assignment.challenge.title
participant = assignment.participant
# 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)
# Calculate expiry
expires_at = dispute.created_at + timedelta(hours=DISPUTE_WINDOW_HOURS)
response.append(MarathonDisputeResponse(
id=dispute.id,
assignment_id=dispute.assignment_id,
bonus_assignment_id=dispute.bonus_assignment_id,
challenge_title=challenge_title,
participant_nickname=participant.user.nickname,
raised_by_nickname=dispute.raised_by.nickname,
reason=dispute.reason,
status=dispute.status,
votes_valid=votes_valid,
votes_invalid=votes_invalid,
created_at=dispute.created_at.isoformat(),
expires_at=expires_at.isoformat(),
))
return response
@router.post("/{marathon_id}/disputes/{dispute_id}/resolve", response_model=MessageResponse)
async def resolve_marathon_dispute(
marathon_id: int,
dispute_id: int,
data: ResolveDisputeRequest,
current_user: CurrentUser,
db: DbSession,
):
"""Manually resolve a dispute in a marathon. Organizers only."""
await require_organizer(db, current_user, marathon_id)
marathon = await get_marathon_or_404(db, marathon_id)
# Get dispute
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.assignment).selectinload(Assignment.participant),
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Dispute.assignment).selectinload(Assignment.game),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
)
.where(Dispute.id == dispute_id)
)
dispute = result.scalar_one_or_none()
if not dispute:
raise HTTPException(status_code=404, detail="Dispute not found")
# Verify dispute belongs to this marathon
if dispute.bonus_assignment_id:
bonus = dispute.bonus_assignment
if bonus.main_assignment.game.marathon_id != marathon_id:
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
else:
assignment = dispute.assignment
if assignment.is_playthrough:
if assignment.game.marathon_id != marathon_id:
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
else:
if assignment.challenge.game.marathon_id != marathon_id:
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
if dispute.status != DisputeStatus.OPEN.value:
raise HTTPException(status_code=400, detail="Dispute is already resolved")
# Determine result
if data.is_valid:
result_status = DisputeStatus.RESOLVED_VALID.value
else:
result_status = DisputeStatus.RESOLVED_INVALID.value
# Handle invalid proof
if dispute.bonus_assignment_id:
# Reset bonus assignment
bonus = dispute.bonus_assignment
main_assignment = bonus.main_assignment
participant = main_assignment.participant
# Only subtract points if main playthrough was already completed
# (bonus points are added only when main playthrough is completed)
if main_assignment.status == AssignmentStatus.COMPLETED.value:
points_to_subtract = bonus.points_earned
participant.total_points = max(0, participant.total_points - points_to_subtract)
# Also reduce the points_earned on the main assignment
main_assignment.points_earned = max(0, main_assignment.points_earned - points_to_subtract)
bonus.status = BonusAssignmentStatus.PENDING.value
bonus.proof_path = None
bonus.proof_url = None
bonus.proof_comment = None
bonus.points_earned = 0
bonus.completed_at = None
else:
# Reset main assignment
assignment = dispute.assignment
participant = assignment.participant
# Subtract points
points_to_subtract = assignment.points_earned
participant.total_points = max(0, participant.total_points - points_to_subtract)
# Reset streak - the completion was invalid
participant.current_streak = 0
# Reset assignment
assignment.status = AssignmentStatus.RETURNED.value
assignment.points_earned = 0
# For playthrough: reset all bonus assignments
if assignment.is_playthrough:
bonus_result = await db.execute(
select(BonusAssignment).where(BonusAssignment.main_assignment_id == assignment.id)
)
for ba in bonus_result.scalars().all():
ba.status = BonusAssignmentStatus.PENDING.value
ba.proof_path = None
ba.proof_url = None
ba.proof_comment = None
ba.points_earned = 0
ba.completed_at = None
# Update dispute
dispute.status = result_status
dispute.resolved_at = datetime.utcnow()
await db.commit()
# Send notification
if dispute.bonus_assignment_id:
participant_user_id = dispute.bonus_assignment.main_assignment.participant.user_id
challenge_title = f"Бонус: {dispute.bonus_assignment.challenge.title}"
elif dispute.assignment.is_playthrough:
participant_user_id = dispute.assignment.participant.user_id
challenge_title = f"Прохождение: {dispute.assignment.game.title}"
else:
participant_user_id = dispute.assignment.participant.user_id
challenge_title = dispute.assignment.challenge.title
await telegram_notifier.notify_dispute_resolved(
db,
user_id=participant_user_id,
marathon_title=marathon.title,
challenge_title=challenge_title,
is_valid=data.is_valid
)
return MessageResponse(
message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}"
)

View File

@@ -86,7 +86,7 @@ async def generate_link_token(current_user: CurrentUser):
) )
logger.info(f"[TG_LINK] Token created: {token} (length: {len(token)})") logger.info(f"[TG_LINK] Token created: {token} (length: {len(token)})")
bot_username = settings.TELEGRAM_BOT_USERNAME or "GameMarathonBot" bot_username = settings.TELEGRAM_BOT_USERNAME or "BCMarathonbot"
bot_url = f"https://t.me/{bot_username}?start={token}" bot_url = f"https://t.me/{bot_username}?start={token}"
logger.info(f"[TG_LINK] Bot URL: {bot_url}") logger.info(f"[TG_LINK] Bot URL: {bot_url}")

View File

@@ -9,15 +9,18 @@ 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, User EventType, Difficulty, User, BonusAssignment, BonusAssignmentStatus, GameType,
DisputeStatus,
) )
from app.schemas import ( from app.schemas import (
SpinResult, AssignmentResponse, CompleteResult, DropResult, SpinResult, AssignmentResponse, CompleteResult, DropResult,
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse,
) )
from app.schemas.game import PlaythroughInfo
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 from app.services.storage import storage_service
from app.api.v1.games import get_available_games_for_participant
router = APIRouter(tags=["wheel"]) router = APIRouter(tags=["wheel"])
@@ -48,7 +51,9 @@ async def get_active_assignment(db, participant_id: int, is_event: bool = False)
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
selectinload(Assignment.challenge).selectinload(Challenge.game) selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game).selectinload(Game.challenges), # For playthrough
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses
) )
.where( .where(
Assignment.participant_id == participant_id, Assignment.participant_id == participant_id,
@@ -64,7 +69,9 @@ async def get_oldest_returned_assignment(db, participant_id: int) -> Assignment
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
selectinload(Assignment.challenge).selectinload(Challenge.game) selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game).selectinload(Game.challenges), # For playthrough
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses
) )
.where( .where(
Assignment.participant_id == participant_id, Assignment.participant_id == participant_id,
@@ -94,7 +101,7 @@ async def activate_returned_assignment(db, returned_assignment: Assignment) -> N
@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 (or playthrough)"""
# Check marathon is active # Check marathon is active
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id)) result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none() marathon = result.scalar_one_or_none()
@@ -115,43 +122,110 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
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")
# Get available games (filtered by completion status)
available_games, _ = await get_available_games_for_participant(db, participant, marathon_id)
if not available_games:
raise HTTPException(status_code=400, detail="No games available for spin")
# Check active event # Check active event
active_event = await event_service.get_active_event(db, marathon_id) active_event = await event_service.get_active_event(db, marathon_id)
game = None game = None
challenge = None challenge = None
is_playthrough = False
# Handle special event cases (excluding Common Enemy - it has separate flow) # Handle special event cases (excluding Common Enemy - it has separate flow)
# Events only apply to challenges-type games, not playthrough
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 (from challenges-type games)
challenge = await event_service.get_random_hard_challenge(db, marathon_id) challenge = await event_service.get_random_hard_challenge(db, marathon_id)
if challenge: if challenge:
# Load game for challenge # Check if this game is available for the participant
result = await db.execute( result = await db.execute(
select(Game).where(Game.id == challenge.game_id) select(Game).where(Game.id == challenge.game_id)
) )
game = result.scalar_one_or_none() game = result.scalar_one_or_none()
if game and game.id in [g.id for g in available_games]:
# 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)
else:
# Game not available, fall back to normal selection
game = None
challenge = None
# Note: Common Enemy is handled separately via event-assignment endpoints # Note: Common Enemy is handled separately via event-assignment endpoints
# 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:
game = random.choice(available_games)
if game.game_type == GameType.PLAYTHROUGH.value:
# Playthrough game - no challenge selection, ignore events
is_playthrough = True
challenge = None
active_event = None # Ignore events for playthrough
else:
# Challenges game - select random challenge
if not game.challenges:
# Reload challenges if not loaded
result = await db.execute( result = await db.execute(
select(Game) select(Game)
.options(selectinload(Game.challenges)) .options(selectinload(Game.challenges))
.where(Game.marathon_id == marathon_id) .where(Game.id == game.id)
) )
games = [g for g in result.scalars().all() if g.challenges] game = result.scalar_one()
if not games: # Filter out already completed challenges
raise HTTPException(status_code=400, detail="No games with challenges available") completed_result = await db.execute(
select(Assignment.challenge_id)
.where(
Assignment.participant_id == participant.id,
Assignment.challenge_id.in_([c.id for c in game.challenges]),
Assignment.status == AssignmentStatus.COMPLETED.value,
)
)
completed_ids = set(completed_result.scalars().all())
available_challenges = [c for c in game.challenges if c.id not in completed_ids]
game = random.choice(games) if not available_challenges:
challenge = random.choice(game.challenges) raise HTTPException(status_code=400, detail="No challenges available for this game")
# Create assignment (store event_type for jackpot multiplier on completion) challenge = random.choice(available_challenges)
# Create assignment
if is_playthrough:
# Playthrough assignment - link to game, not challenge
assignment = Assignment(
participant_id=participant.id,
game_id=game.id,
is_playthrough=True,
status=AssignmentStatus.ACTIVE.value,
# No event_type for playthrough
)
db.add(assignment)
await db.flush() # Get assignment.id for bonus assignments
# Create bonus assignments for all challenges
bonus_challenges = []
if game.challenges:
for ch in game.challenges:
bonus = BonusAssignment(
main_assignment_id=assignment.id,
challenge_id=ch.id,
)
db.add(bonus)
bonus_challenges.append(ch)
# Log activity
activity_data = {
"game": game.title,
"is_playthrough": True,
"points": game.playthrough_points,
"bonus_challenges_count": len(bonus_challenges),
}
else:
# Regular challenge assignment
assignment = Assignment( assignment = Assignment(
participant_id=participant.id, participant_id=participant.id,
challenge_id=challenge.id, challenge_id=challenge.id,
@@ -181,10 +255,17 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
await db.commit() await db.commit()
await db.refresh(assignment) await db.refresh(assignment)
# Calculate drop penalty (considers active event for double_risk) # Calculate drop penalty
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, challenge.points, active_event) if is_playthrough:
drop_penalty = points_service.calculate_drop_penalty(
participant.drop_count, game.playthrough_points, None # No events for playthrough
)
else:
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
challenges_count = 0 challenges_count = 0
if 'challenges' in game.__dict__: if 'challenges' in game.__dict__:
challenges_count = len(game.challenges) challenges_count = len(game.challenges)
@@ -193,9 +274,8 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id) select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
) )
return SpinResult( # Build response
assignment_id=assignment.id, game_response = GameResponse(
game=GameResponse(
id=game.id, id=game.id,
title=game.title, title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"), cover_url=storage_service.get_url(game.cover_path, "covers"),
@@ -204,7 +284,51 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
added_by=None, added_by=None,
challenges_count=challenges_count, challenges_count=challenges_count,
created_at=game.created_at, created_at=game.created_at,
game_type=game.game_type,
playthrough_points=game.playthrough_points,
playthrough_description=game.playthrough_description,
playthrough_proof_type=game.playthrough_proof_type,
playthrough_proof_hint=game.playthrough_proof_hint,
)
if is_playthrough:
# Return playthrough result
return SpinResult(
assignment_id=assignment.id,
game=game_response,
challenge=None,
is_playthrough=True,
playthrough_info=PlaythroughInfo(
description=game.playthrough_description,
points=game.playthrough_points,
proof_type=game.playthrough_proof_type,
proof_hint=game.playthrough_proof_hint,
), ),
bonus_challenges=[
ChallengeResponse(
id=ch.id,
title=ch.title,
description=ch.description,
type=ch.type,
difficulty=ch.difficulty,
points=ch.points,
estimated_time=ch.estimated_time,
proof_type=ch.proof_type,
proof_hint=ch.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
is_generated=ch.is_generated,
created_at=ch.created_at,
)
for ch in bonus_challenges
],
can_drop=True,
drop_penalty=drop_penalty,
)
else:
# Return challenge result
return SpinResult(
assignment_id=assignment.id,
game=game_response,
challenge=ChallengeResponse( challenge=ChallengeResponse(
id=challenge.id, id=challenge.id,
title=challenge.title, title=challenge.title,
@@ -215,10 +339,11 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
estimated_time=challenge.estimated_time, estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type, proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint, proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None), game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
is_generated=challenge.is_generated, is_generated=challenge.is_generated,
created_at=challenge.created_at, created_at=challenge.created_at,
), ),
is_playthrough=False,
can_drop=True, can_drop=True,
drop_penalty=drop_penalty, drop_penalty=drop_penalty,
) )
@@ -230,9 +355,77 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
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, is_event=False) assignment = await get_active_assignment(db, participant.id, is_event=False)
# If no active assignment, check for returned assignments
if not assignment:
returned = await get_oldest_returned_assignment(db, participant.id)
if returned:
# Activate the returned assignment
await activate_returned_assignment(db, returned)
await db.commit()
# Reload with all relationships
assignment = await get_active_assignment(db, participant.id, is_event=False)
if not assignment: if not assignment:
return None return None
# Handle playthrough assignments
if assignment.is_playthrough:
game = assignment.game
active_event = None # No events for playthrough
drop_penalty = points_service.calculate_drop_penalty(
participant.drop_count, game.playthrough_points, None
)
# Build bonus challenges response
from app.schemas.assignment import BonusAssignmentResponse
bonus_responses = []
for ba in assignment.bonus_assignments:
bonus_responses.append(BonusAssignmentResponse(
id=ba.id,
challenge=ChallengeResponse(
id=ba.challenge.id,
title=ba.challenge.title,
description=ba.challenge.description,
type=ba.challenge.type,
difficulty=ba.challenge.difficulty,
points=ba.challenge.points,
estimated_time=ba.challenge.estimated_time,
proof_type=ba.challenge.proof_type,
proof_hint=ba.challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
is_generated=ba.challenge.is_generated,
created_at=ba.challenge.created_at,
),
status=ba.status,
proof_url=ba.proof_url,
proof_comment=ba.proof_comment,
points_earned=ba.points_earned,
completed_at=ba.completed_at,
))
return AssignmentResponse(
id=assignment.id,
challenge=None,
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
is_playthrough=True,
playthrough_info=PlaythroughInfo(
description=game.playthrough_description,
points=game.playthrough_points,
proof_type=game.playthrough_proof_type,
proof_hint=game.playthrough_proof_hint,
),
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,
drop_penalty=drop_penalty,
bonus_challenges=bonus_responses,
)
# Regular challenge assignment
challenge = assignment.challenge challenge = assignment.challenge
game = challenge.game game = challenge.game
@@ -252,7 +445,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
estimated_time=challenge.estimated_time, estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type, proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint, proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None), game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
is_generated=challenge.is_generated, is_generated=challenge.is_generated,
created_at=challenge.created_at, created_at=challenge.created_at,
), ),
@@ -274,15 +467,19 @@ async def complete_assignment(
db: DbSession, db: DbSession,
proof_url: str | None = Form(None), proof_url: str | None = Form(None),
comment: str | None = Form(None), comment: str | None = Form(None),
proof_file: UploadFile | None = File(None), proof_file: UploadFile | None = File(None), # Legacy single file support
proof_files: list[UploadFile] = File([]), # Multiple files support
): ):
"""Complete a regular assignment with proof (not event assignments)""" """Complete a regular assignment with proof (not event assignments)"""
# Get assignment # Get assignment with all needed relationships
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
selectinload(Assignment.participant), selectinload(Assignment.participant),
selectinload(Assignment.challenge), selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For bonus points
selectinload(Assignment.dispute), # To check if it was previously disputed
) )
.where(Assignment.id == assignment_id) .where(Assignment.id == assignment_id)
) )
@@ -301,62 +498,157 @@ async def complete_assignment(
if assignment.is_event_assignment: if assignment.is_event_assignment:
raise HTTPException(status_code=400, detail="Use /event-assignments/{id}/complete for event assignments") raise HTTPException(status_code=400, detail="Use /event-assignments/{id}/complete for event assignments")
# Need either file or URL # Combine legacy single file with new multiple files
if not proof_file and not proof_url: all_files = []
if proof_file:
all_files.append(proof_file)
if proof_files:
all_files.extend(proof_files)
# For playthrough: need either file(s) or URL or comment (proof is flexible)
# For challenges: need either file(s) or URL
if assignment.is_playthrough:
if not all_files and not proof_url and not comment:
raise HTTPException(status_code=400, detail="Proof is required (file, URL, or comment)")
else:
if not all_files 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)")
# Handle file upload # Handle multiple file uploads
if proof_file: if all_files:
contents = await proof_file.read() from app.models import AssignmentProof
for idx, file in enumerate(all_files):
contents = await file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE: if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB", detail=f"File {file.filename} 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" ext = file.filename.split(".")[-1].lower() if file.filename else "jpg"
if ext not in settings.ALLOWED_EXTENSIONS: if ext not in settings.ALLOWED_EXTENSIONS:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}", detail=f"Invalid file type for {file.filename}. Allowed: {settings.ALLOWED_EXTENSIONS}",
) )
# Determine file type (image or video)
file_type = "video" if ext in ["mp4", "webm", "mov", "avi"] else "image"
# Upload file to storage # Upload file to storage
filename = storage_service.generate_filename(assignment_id, proof_file.filename) filename = storage_service.generate_filename(f"{assignment_id}_{idx}", file.filename)
file_path = await storage_service.upload_file( file_path = await storage_service.upload_file(
content=contents, content=contents,
folder="proofs", folder="proofs",
filename=filename, filename=filename,
content_type=proof_file.content_type or "application/octet-stream", content_type=file.content_type or "application/octet-stream",
) )
# Create AssignmentProof record
proof_record = AssignmentProof(
assignment_id=assignment_id,
file_path=file_path,
file_type=file_type,
order_index=idx
)
db.add(proof_record)
# Legacy: set proof_path on first file for backward compatibility
if idx == 0:
assignment.proof_path = file_path assignment.proof_path = file_path
else:
# Set proof URL if provided
if proof_url:
assignment.proof_url = proof_url assignment.proof_url = proof_url
assignment.proof_comment = comment assignment.proof_comment = comment
# Calculate points
participant = assignment.participant participant = assignment.participant
challenge = assignment.challenge
# Get marathon_id for activity and event check # Handle playthrough completion
result = await db.execute( if assignment.is_playthrough:
select(Challenge).options(selectinload(Challenge.game)).where(Challenge.id == challenge.id) game = assignment.game
marathon_id = game.marathon_id
base_points = game.playthrough_points
# No events for playthrough
total_points, streak_bonus, _ = points_service.calculate_completion_points(
base_points, participant.current_streak, None
) )
full_challenge = result.scalar_one()
marathon_id = full_challenge.game.marathon_id # Calculate bonus points from completed bonus assignments
bonus_points = sum(
ba.points_earned for ba in assignment.bonus_assignments
if ba.status == BonusAssignmentStatus.COMPLETED.value
)
total_points += bonus_points
# Update assignment
assignment.status = AssignmentStatus.COMPLETED.value
assignment.points_earned = total_points
assignment.streak_at_completion = participant.current_streak + 1
assignment.completed_at = datetime.utcnow()
# Update participant
participant.total_points += total_points
participant.current_streak += 1
participant.drop_count = 0
# Check if this is a redo of a previously disputed assignment
is_redo = (
assignment.dispute is not None and
assignment.dispute.status == DisputeStatus.RESOLVED_INVALID.value
)
# Log activity
activity_data = {
"assignment_id": assignment.id,
"game": game.title,
"is_playthrough": True,
"points": total_points,
"base_points": base_points,
"bonus_points": bonus_points,
"streak": participant.current_streak,
}
if is_redo:
activity_data["is_redo"] = True
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.COMPLETE.value,
data=activity_data,
)
db.add(activity)
await db.commit()
# Check for returned assignments
returned_assignment = await get_oldest_returned_assignment(db, participant.id)
if returned_assignment:
await activate_returned_assignment(db, returned_assignment)
await db.commit()
return CompleteResult(
points_earned=total_points,
streak_bonus=streak_bonus,
total_points=participant.total_points,
new_streak=participant.current_streak,
)
# Regular challenge completion
challenge = assignment.challenge
marathon_id = challenge.game.marathon_id
# 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: 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
effective_event = active_event effective_event = active_event
# Handle assignment-level event types (jackpot) # Handle assignment-level event types (jackpot)
if assignment.event_type == EventType.JACKPOT.value: if assignment.event_type == EventType.JACKPOT.value:
# Create a mock event object for point calculation
class MockEvent: class MockEvent:
def __init__(self, event_type): def __init__(self, event_type):
self.type = event_type self.type = event_type
@@ -386,18 +678,25 @@ async def complete_assignment(
# Update participant # Update participant
participant.total_points += total_points participant.total_points += total_points
participant.current_streak += 1 participant.current_streak += 1
participant.drop_count = 0 # Reset drop counter on success participant.drop_count = 0
# Check if this is a redo of a previously disputed assignment
is_redo = (
assignment.dispute is not None and
assignment.dispute.status == DisputeStatus.RESOLVED_INVALID.value
)
# Log activity # Log activity
activity_data = { activity_data = {
"assignment_id": assignment.id, "assignment_id": assignment.id,
"game": full_challenge.game.title, "game": challenge.game.title,
"challenge": challenge.title, "challenge": challenge.title,
"difficulty": challenge.difficulty, "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, active_event for others) if is_redo:
activity_data["is_redo"] = True
if assignment.event_type == EventType.JACKPOT.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
@@ -418,7 +717,6 @@ 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] winner_user_ids = [w["user_id"] for w in common_enemy_winners]
users_result = await db.execute( users_result = await db.execute(
select(User).where(User.id.in_(winner_user_ids)) select(User).where(User.id.in_(winner_user_ids))
@@ -438,7 +736,7 @@ async def complete_assignment(
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,
type=ActivityType.EVENT_END.value, type=ActivityType.EVENT_END.value,
data={ data={
"event_type": EventType.COMMON_ENEMY.value, "event_type": EventType.COMMON_ENEMY.value,
@@ -451,7 +749,7 @@ async def complete_assignment(
await db.commit() await db.commit()
# Check for returned assignments and activate the oldest one # Check for returned assignments
returned_assignment = await get_oldest_returned_assignment(db, participant.id) returned_assignment = await get_oldest_returned_assignment(db, participant.id)
if returned_assignment: if returned_assignment:
await activate_returned_assignment(db, returned_assignment) await activate_returned_assignment(db, returned_assignment)
@@ -469,12 +767,14 @@ async def complete_assignment(
@router.post("/assignments/{assignment_id}/drop", response_model=DropResult) @router.post("/assignments/{assignment_id}/drop", response_model=DropResult)
async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession): async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession):
"""Drop current assignment""" """Drop current assignment"""
# Get assignment # Get assignment with all needed relationships
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
selectinload(Assignment.participant), selectinload(Assignment.participant),
selectinload(Assignment.challenge).selectinload(Challenge.game), selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
selectinload(Assignment.bonus_assignments), # For resetting bonuses on drop
) )
.where(Assignment.id == assignment_id) .where(Assignment.id == assignment_id)
) )
@@ -490,6 +790,61 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
raise HTTPException(status_code=400, detail="Assignment is not active") raise HTTPException(status_code=400, detail="Assignment is not active")
participant = assignment.participant participant = assignment.participant
# Handle playthrough drop
if assignment.is_playthrough:
game = assignment.game
marathon_id = game.marathon_id
# No events for playthrough
penalty = points_service.calculate_drop_penalty(
participant.drop_count, game.playthrough_points, None
)
# Update assignment
assignment.status = AssignmentStatus.DROPPED.value
assignment.completed_at = datetime.utcnow()
# Reset all bonus assignments (lose any completed bonuses)
completed_bonuses_count = 0
for ba in assignment.bonus_assignments:
if ba.status == BonusAssignmentStatus.COMPLETED.value:
completed_bonuses_count += 1
ba.status = BonusAssignmentStatus.PENDING.value
ba.proof_path = None
ba.proof_url = None
ba.proof_comment = None
ba.points_earned = 0
ba.completed_at = None
# Update participant
participant.total_points = max(0, participant.total_points - penalty)
participant.current_streak = 0
participant.drop_count += 1
# Log activity
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.DROP.value,
data={
"game": game.title,
"is_playthrough": True,
"penalty": penalty,
"lost_bonuses": completed_bonuses_count,
},
)
db.add(activity)
await db.commit()
return DropResult(
penalty=penalty,
total_points=participant.total_points,
new_drop_count=participant.drop_count,
)
# Regular challenge drop
marathon_id = assignment.challenge.game.marathon_id marathon_id = assignment.challenge.game.marathon_id
# Check active event for free drops (double_risk) # Check active event for free drops (double_risk)
@@ -550,7 +905,9 @@ async def get_my_history(
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
selectinload(Assignment.challenge).selectinload(Challenge.game) selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses
) )
.where(Assignment.participant_id == participant.id) .where(Assignment.participant_id == participant.id)
.order_by(Assignment.started_at.desc()) .order_by(Assignment.started_at.desc())
@@ -559,8 +916,61 @@ async def get_my_history(
) )
assignments = result.scalars().all() assignments = result.scalars().all()
return [ responses = []
AssignmentResponse( for a in assignments:
if a.is_playthrough:
# Playthrough assignment
game = a.game
from app.schemas.assignment import BonusAssignmentResponse
bonus_responses = [
BonusAssignmentResponse(
id=ba.id,
challenge=ChallengeResponse(
id=ba.challenge.id,
title=ba.challenge.title,
description=ba.challenge.description,
type=ba.challenge.type,
difficulty=ba.challenge.difficulty,
points=ba.challenge.points,
estimated_time=ba.challenge.estimated_time,
proof_type=ba.challenge.proof_type,
proof_hint=ba.challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
is_generated=ba.challenge.is_generated,
created_at=ba.challenge.created_at,
),
status=ba.status,
proof_url=ba.proof_url,
proof_comment=ba.proof_comment,
points_earned=ba.points_earned,
completed_at=ba.completed_at,
)
for ba in a.bonus_assignments
]
responses.append(AssignmentResponse(
id=a.id,
challenge=None,
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
is_playthrough=True,
playthrough_info=PlaythroughInfo(
description=game.playthrough_description,
points=game.playthrough_points,
proof_type=game.playthrough_proof_type,
proof_hint=game.playthrough_proof_hint,
),
status=a.status,
proof_url=storage_service.get_url(a.proof_path, "proofs") if a.proof_path else a.proof_url,
proof_comment=a.proof_comment,
points_earned=a.points_earned,
streak_at_completion=a.streak_at_completion,
started_at=a.started_at,
completed_at=a.completed_at,
bonus_challenges=bonus_responses,
))
else:
# Regular challenge assignment
responses.append(AssignmentResponse(
id=a.id, id=a.id,
challenge=ChallengeResponse( challenge=ChallengeResponse(
id=a.challenge.id, id=a.challenge.id,
@@ -575,7 +985,9 @@ async def get_my_history(
game=GameShort( game=GameShort(
id=a.challenge.game.id, id=a.challenge.game.id,
title=a.challenge.game.title, title=a.challenge.game.title,
cover_url=None cover_url=None,
download_url=a.challenge.game.download_url,
game_type=a.challenge.game.game_type,
), ),
is_generated=a.challenge.is_generated, is_generated=a.challenge.is_generated,
created_at=a.challenge.created_at, created_at=a.challenge.created_at,
@@ -587,6 +999,6 @@ async def get_my_history(
streak_at_completion=a.streak_at_completion, streak_at_completion=a.streak_at_completion,
started_at=a.started_at, started_at=a.started_at,
completed_at=a.completed_at, completed_at=a.completed_at,
) ))
for a in assignments
] return responses

View File

@@ -6,6 +6,7 @@ class Settings(BaseSettings):
# App # App
APP_NAME: str = "Game Marathon" APP_NAME: str = "Game Marathon"
DEBUG: bool = False DEBUG: bool = False
RATE_LIMIT_ENABLED: bool = True # Set to False to disable rate limiting
# Database # Database
DATABASE_URL: str = "postgresql+asyncpg://marathon:marathon@localhost:5432/marathon" DATABASE_URL: str = "postgresql+asyncpg://marathon:marathon@localhost:5432/marathon"

View File

@@ -1,5 +1,10 @@
from slowapi import Limiter from slowapi import Limiter
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
from app.core.config import settings
# Rate limiter using client IP address as key # Rate limiter using client IP address as key
limiter = Limiter(key_func=get_remote_address) # Can be disabled via RATE_LIMIT_ENABLED=false in .env
limiter = Limiter(
key_func=get_remote_address,
enabled=settings.RATE_LIMIT_ENABLED
)

View File

@@ -1,13 +1,18 @@
from app.models.user import User, UserRole from app.models.user import User, UserRole
from app.models.marathon import Marathon, MarathonStatus, GameProposalMode from app.models.marathon import Marathon, MarathonStatus, GameProposalMode
from app.models.participant import Participant, ParticipantRole from app.models.participant import Participant, ParticipantRole
from app.models.game import Game, GameStatus from app.models.game import Game, GameStatus, GameType
from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType
from app.models.assignment import Assignment, AssignmentStatus from app.models.assignment import Assignment, AssignmentStatus
from app.models.bonus_assignment import BonusAssignment, BonusAssignmentStatus
from app.models.assignment_proof import AssignmentProof, BonusAssignmentProof
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 from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVote
from app.models.admin_log import AdminLog, AdminActionType
from app.models.admin_2fa import Admin2FASession
from app.models.static_content import StaticContent
__all__ = [ __all__ = [
"User", "User",
@@ -19,12 +24,17 @@ __all__ = [
"ParticipantRole", "ParticipantRole",
"Game", "Game",
"GameStatus", "GameStatus",
"GameType",
"Challenge", "Challenge",
"ChallengeType", "ChallengeType",
"Difficulty", "Difficulty",
"ProofType", "ProofType",
"Assignment", "Assignment",
"AssignmentStatus", "AssignmentStatus",
"BonusAssignment",
"BonusAssignmentStatus",
"AssignmentProof",
"BonusAssignmentProof",
"Activity", "Activity",
"ActivityType", "ActivityType",
"Event", "Event",
@@ -35,4 +45,8 @@ __all__ = [
"DisputeStatus", "DisputeStatus",
"DisputeComment", "DisputeComment",
"DisputeVote", "DisputeVote",
"AdminLog",
"AdminActionType",
"Admin2FASession",
"StaticContent",
] ]

View File

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

View File

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

View File

@@ -18,8 +18,12 @@ class Assignment(Base):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"), index=True) participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"), index=True)
challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE")) challenge_id: Mapped[int | None] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"), nullable=True) # None для playthrough
status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value) status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value)
# Для прохождений (playthrough)
game_id: Mapped[int | None] = mapped_column(ForeignKey("games.id", ondelete="CASCADE"), nullable=True, index=True)
is_playthrough: Mapped[bool] = mapped_column(Boolean, default=False)
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 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 event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id", ondelete="SET NULL"), nullable=True, index=True) # Link to event
@@ -33,6 +37,9 @@ 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 | None"] = relationship("Challenge", back_populates="assignments")
game: Mapped["Game | None"] = relationship("Game", back_populates="playthrough_assignments", foreign_keys=[game_id])
event: Mapped["Event | None"] = relationship("Event", 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) dispute: Mapped["Dispute | None"] = relationship("Dispute", back_populates="assignment", uselist=False, cascade="all, delete-orphan", passive_deletes=True)
bonus_assignments: Mapped[list["BonusAssignment"]] = relationship("BonusAssignment", back_populates="main_assignment", cascade="all, delete-orphan")
proof_files: Mapped[list["AssignmentProof"]] = relationship("AssignmentProof", back_populates="assignment", cascade="all, delete-orphan", order_by="AssignmentProof.order_index")

View File

@@ -0,0 +1,47 @@
from datetime import datetime
from sqlalchemy import String, ForeignKey, Integer, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class AssignmentProof(Base):
"""Файлы-доказательства для заданий (множественные пруфы)"""
__tablename__ = "assignment_proofs"
id: Mapped[int] = mapped_column(primary_key=True)
assignment_id: Mapped[int] = mapped_column(
ForeignKey("assignments.id", ondelete="CASCADE"),
index=True
)
file_path: Mapped[str] = mapped_column(String(500)) # Путь к файлу в хранилище
file_type: Mapped[str] = mapped_column(String(20)) # image или video
order_index: Mapped[int] = mapped_column(Integer, default=0) # Порядок отображения
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
assignment: Mapped["Assignment"] = relationship(
"Assignment",
back_populates="proof_files"
)
class BonusAssignmentProof(Base):
"""Файлы-доказательства для бонусных заданий (множественные пруфы)"""
__tablename__ = "bonus_assignment_proofs"
id: Mapped[int] = mapped_column(primary_key=True)
bonus_assignment_id: Mapped[int] = mapped_column(
ForeignKey("bonus_assignments.id", ondelete="CASCADE"),
index=True
)
file_path: Mapped[str] = mapped_column(String(500)) # Путь к файлу в хранилище
file_type: Mapped[str] = mapped_column(String(20)) # image или video
order_index: Mapped[int] = mapped_column(Integer, default=0) # Порядок отображения
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
bonus_assignment: Mapped["BonusAssignment"] = relationship(
"BonusAssignment",
back_populates="proof_files"
)

View File

@@ -0,0 +1,54 @@
from datetime import datetime
from enum import Enum
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class BonusAssignmentStatus(str, Enum):
PENDING = "pending"
COMPLETED = "completed"
class BonusAssignment(Base):
"""Бонусные челленджи для игр типа 'playthrough'"""
__tablename__ = "bonus_assignments"
id: Mapped[int] = mapped_column(primary_key=True)
main_assignment_id: Mapped[int] = mapped_column(
ForeignKey("assignments.id", ondelete="CASCADE"),
index=True
)
challenge_id: Mapped[int] = mapped_column(
ForeignKey("challenges.id", ondelete="CASCADE"),
index=True
)
status: Mapped[str] = mapped_column(
String(20),
default=BonusAssignmentStatus.PENDING.value
)
proof_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
proof_url: Mapped[str | None] = mapped_column(Text, nullable=True)
proof_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
points_earned: Mapped[int] = mapped_column(Integer, default=0)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
main_assignment: Mapped["Assignment"] = relationship(
"Assignment",
back_populates="bonus_assignments"
)
challenge: Mapped["Challenge"] = relationship("Challenge")
dispute: Mapped["Dispute"] = relationship(
"Dispute",
back_populates="bonus_assignment",
uselist=False,
)
proof_files: Mapped[list["BonusAssignmentProof"]] = relationship(
"BonusAssignmentProof",
back_populates="bonus_assignment",
cascade="all, delete-orphan",
order_by="BonusAssignmentProof.order_index"
)

View File

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

View File

@@ -8,16 +8,19 @@ from app.core.database import Base
class DisputeStatus(str, Enum): class DisputeStatus(str, Enum):
OPEN = "open" OPEN = "open"
PENDING_ADMIN = "pending_admin" # Voting ended, waiting for admin decision
RESOLVED_VALID = "valid" RESOLVED_VALID = "valid"
RESOLVED_INVALID = "invalid" RESOLVED_INVALID = "invalid"
class Dispute(Base): class Dispute(Base):
"""Dispute against a completed assignment's proof""" """Dispute against a completed assignment's or bonus assignment's proof"""
__tablename__ = "disputes" __tablename__ = "disputes"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
assignment_id: Mapped[int] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), unique=True, index=True) # Either assignment_id OR bonus_assignment_id should be set (not both)
assignment_id: Mapped[int | None] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), nullable=True, index=True)
bonus_assignment_id: Mapped[int | None] = mapped_column(ForeignKey("bonus_assignments.id", ondelete="CASCADE"), nullable=True, index=True)
raised_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) raised_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
reason: Mapped[str] = mapped_column(Text, nullable=False) reason: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(String(20), default=DisputeStatus.OPEN.value) status: Mapped[str] = mapped_column(String(20), default=DisputeStatus.OPEN.value)
@@ -26,6 +29,7 @@ class Dispute(Base):
# Relationships # Relationships
assignment: Mapped["Assignment"] = relationship("Assignment", back_populates="dispute") assignment: Mapped["Assignment"] = relationship("Assignment", back_populates="dispute")
bonus_assignment: Mapped["BonusAssignment"] = relationship("BonusAssignment", back_populates="dispute")
raised_by: Mapped["User"] = relationship("User", foreign_keys=[raised_by_id]) raised_by: Mapped["User"] = relationship("User", foreign_keys=[raised_by_id])
comments: Mapped[list["DisputeComment"]] = relationship("DisputeComment", back_populates="dispute", cascade="all, delete-orphan") 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") votes: Mapped[list["DisputeVote"]] = relationship("DisputeVote", back_populates="dispute", cascade="all, delete-orphan")

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, DateTime, ForeignKey, Text from sqlalchemy import String, DateTime, ForeignKey, Text, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base from app.core.database import Base
@@ -12,6 +12,11 @@ class GameStatus(str, Enum):
REJECTED = "rejected" # Отклонена REJECTED = "rejected" # Отклонена
class GameType(str, Enum):
PLAYTHROUGH = "playthrough" # Прохождение игры
CHALLENGES = "challenges" # Челленджи
class Game(Base): class Game(Base):
__tablename__ = "games" __tablename__ = "games"
@@ -26,6 +31,15 @@ class Game(Base):
approved_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) approved_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Тип игры
game_type: Mapped[str] = mapped_column(String(20), default=GameType.CHALLENGES.value, nullable=False)
# Поля для типа "Прохождение" (заполняются только для playthrough)
playthrough_points: Mapped[int | None] = mapped_column(Integer, nullable=True)
playthrough_description: Mapped[str | None] = mapped_column(Text, nullable=True)
playthrough_proof_type: Mapped[str | None] = mapped_column(String(20), nullable=True) # screenshot, video, steam
playthrough_proof_hint: Mapped[str | None] = mapped_column(Text, nullable=True)
# Relationships # Relationships
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="games") marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="games")
proposed_by: Mapped["User"] = relationship( proposed_by: Mapped["User"] = relationship(
@@ -43,6 +57,12 @@ class Game(Base):
back_populates="game", back_populates="game",
cascade="all, delete-orphan" cascade="all, delete-orphan"
) )
# Assignments для прохождений (playthrough)
playthrough_assignments: Mapped[list["Assignment"]] = relationship(
"Assignment",
back_populates="game",
foreign_keys="Assignment.game_id"
)
@property @property
def is_approved(self) -> bool: def is_approved(self) -> bool:
@@ -51,3 +71,11 @@ class Game(Base):
@property @property
def is_pending(self) -> bool: def is_pending(self) -> bool:
return self.status == GameStatus.PENDING.value return self.status == GameStatus.PENDING.value
@property
def is_playthrough(self) -> bool:
return self.game_type == GameType.PLAYTHROUGH.value
@property
def is_challenges(self) -> bool:
return self.game_type == GameType.CHALLENGES.value

View File

@@ -31,6 +31,8 @@ class Marathon(Base):
start_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) start_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
end_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) end_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
auto_events_enabled: Mapped[bool] = mapped_column(Boolean, default=True) auto_events_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
cover_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
cover_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships # Relationships

View File

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

View File

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

View File

@@ -46,6 +46,10 @@ from app.schemas.assignment import (
CompleteResult, CompleteResult,
DropResult, DropResult,
EventAssignmentResponse, EventAssignmentResponse,
BonusAssignmentResponse,
CompleteBonusAssignment,
BonusCompleteResult,
AvailableGamesCount,
) )
from app.schemas.activity import ( from app.schemas.activity import (
ActivityResponse, ActivityResponse,
@@ -81,6 +85,23 @@ from app.schemas.dispute import (
AssignmentDetailResponse, AssignmentDetailResponse,
ReturnedAssignmentResponse, ReturnedAssignmentResponse,
) )
from app.schemas.admin import (
BanUserRequest,
AdminResetPasswordRequest,
AdminUserResponse,
AdminLogResponse,
AdminLogsListResponse,
BroadcastRequest,
BroadcastResponse,
StaticContentResponse,
StaticContentUpdate,
StaticContentCreate,
TwoFactorInitiateRequest,
TwoFactorInitiateResponse,
TwoFactorVerifyRequest,
LoginResponse,
DashboardStats,
)
__all__ = [ __all__ = [
# User # User
@@ -127,6 +148,10 @@ __all__ = [
"CompleteResult", "CompleteResult",
"DropResult", "DropResult",
"EventAssignmentResponse", "EventAssignmentResponse",
"BonusAssignmentResponse",
"CompleteBonusAssignment",
"BonusCompleteResult",
"AvailableGamesCount",
# Activity # Activity
"ActivityResponse", "ActivityResponse",
"FeedResponse", "FeedResponse",
@@ -157,4 +182,20 @@ __all__ = [
"DisputeResponse", "DisputeResponse",
"AssignmentDetailResponse", "AssignmentDetailResponse",
"ReturnedAssignmentResponse", "ReturnedAssignmentResponse",
# Admin
"BanUserRequest",
"AdminResetPasswordRequest",
"AdminUserResponse",
"AdminLogResponse",
"AdminLogsListResponse",
"BroadcastRequest",
"BroadcastResponse",
"StaticContentResponse",
"StaticContentUpdate",
"StaticContentCreate",
"TwoFactorInitiateRequest",
"TwoFactorInitiateResponse",
"TwoFactorVerifyRequest",
"LoginResponse",
"DashboardStats",
] ]

View File

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

View File

@@ -1,10 +1,21 @@
from datetime import datetime from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel
from app.schemas.game import GameResponse from app.schemas.game import GameResponse, GameShort, PlaythroughInfo
from app.schemas.challenge import ChallengeResponse from app.schemas.challenge import ChallengeResponse
class ProofFileResponse(BaseModel):
"""Информация о файле-доказательстве"""
id: int
file_type: str # image или video
order_index: int
created_at: datetime
class Config:
from_attributes = True
class AssignmentBase(BaseModel): class AssignmentBase(BaseModel):
pass pass
@@ -14,9 +25,28 @@ class CompleteAssignment(BaseModel):
comment: str | None = None comment: str | None = None
class AssignmentResponse(BaseModel): class BonusAssignmentResponse(BaseModel):
"""Ответ с информацией о бонусном челлендже"""
id: int id: int
challenge: ChallengeResponse challenge: ChallengeResponse
status: str # pending, completed
proof_url: str | None = None
proof_image_url: str | None = None # Legacy, for backward compatibility
proof_files: list[ProofFileResponse] = [] # Multiple uploaded files
proof_comment: str | None = None
points_earned: int = 0
completed_at: datetime | None = None
class Config:
from_attributes = True
class AssignmentResponse(BaseModel):
id: int
challenge: ChallengeResponse | None # None для playthrough
game: GameShort | None = None # Заполняется для playthrough
is_playthrough: bool = False
playthrough_info: PlaythroughInfo | None = None # Заполняется для playthrough
status: str status: str
proof_url: str | None = None proof_url: str | None = None
proof_comment: str | None = None proof_comment: str | None = None
@@ -25,6 +55,7 @@ class AssignmentResponse(BaseModel):
started_at: datetime started_at: datetime
completed_at: datetime | None = None completed_at: datetime | None = None
drop_penalty: int = 0 # Calculated penalty if dropped drop_penalty: int = 0 # Calculated penalty if dropped
bonus_challenges: list[BonusAssignmentResponse] = [] # Для playthrough
class Config: class Config:
from_attributes = True from_attributes = True
@@ -33,7 +64,10 @@ class AssignmentResponse(BaseModel):
class SpinResult(BaseModel): class SpinResult(BaseModel):
assignment_id: int assignment_id: int
game: GameResponse game: GameResponse
challenge: ChallengeResponse challenge: ChallengeResponse | None # None для playthrough
is_playthrough: bool = False
playthrough_info: PlaythroughInfo | None = None # Заполняется для playthrough
bonus_challenges: list[ChallengeResponse] = [] # Для playthrough - список доступных бонусных челленджей
can_drop: bool can_drop: bool
drop_penalty: int drop_penalty: int
@@ -60,3 +94,22 @@ class EventAssignmentResponse(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
class CompleteBonusAssignment(BaseModel):
"""Запрос на завершение бонусного челленджа"""
proof_url: str | None = None
comment: str | None = None
class BonusCompleteResult(BaseModel):
"""Результат завершения бонусного челленджа"""
bonus_assignment_id: int
points_earned: int
total_bonus_points: int # Сумма очков за все бонусные челленджи
class AvailableGamesCount(BaseModel):
"""Количество доступных игр для спина"""
available: int
total: int

View File

@@ -1,16 +1,25 @@
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.models.challenge import ChallengeType, Difficulty, ProofType from app.models.challenge import ChallengeType, Difficulty, ProofType, ChallengeStatus
from app.schemas.game import GameShort from app.schemas.game import GameShort
class ProposedByUser(BaseModel):
"""Minimal user info for proposed challenges"""
id: int
nickname: str
class Config:
from_attributes = True
class ChallengeBase(BaseModel): class ChallengeBase(BaseModel):
title: str = Field(..., min_length=1, max_length=100) title: str = Field(..., min_length=1, max_length=100)
description: str = Field(..., min_length=1) description: str = Field(..., min_length=1)
type: ChallengeType type: ChallengeType
difficulty: Difficulty difficulty: Difficulty
points: int = Field(..., ge=1, le=500) points: int = Field(..., ge=1, le=1000)
estimated_time: int | None = Field(None, ge=1) # minutes estimated_time: int | None = Field(None, ge=1) # minutes
proof_type: ProofType proof_type: ProofType
proof_hint: str | None = None proof_hint: str | None = None
@@ -25,7 +34,7 @@ class ChallengeUpdate(BaseModel):
description: str | None = None description: str | None = None
type: ChallengeType | None = None type: ChallengeType | None = None
difficulty: Difficulty | None = None difficulty: Difficulty | None = None
points: int | None = Field(None, ge=1, le=500) points: int | None = Field(None, ge=1, le=1000)
estimated_time: int | None = None estimated_time: int | None = None
proof_type: ProofType | None = None proof_type: ProofType | None = None
proof_hint: str | None = None proof_hint: str | None = None
@@ -36,11 +45,18 @@ class ChallengeResponse(ChallengeBase):
game: GameShort game: GameShort
is_generated: bool is_generated: bool
created_at: datetime created_at: datetime
status: str = "approved"
proposed_by: ProposedByUser | None = None
class Config: class Config:
from_attributes = True from_attributes = True
class ChallengePropose(ChallengeBase):
"""Schema for proposing a challenge by a participant"""
pass
class ChallengeGenerated(BaseModel): class ChallengeGenerated(BaseModel):
"""Schema for GPT-generated challenges""" """Schema for GPT-generated challenges"""
title: str title: str

View File

@@ -1,8 +1,14 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.schemas.user import UserPublic from app.schemas.user import UserPublic
from app.schemas.challenge import ChallengeResponse from app.schemas.challenge import ChallengeResponse, GameShort
from app.schemas.assignment import ProofFileResponse
if TYPE_CHECKING:
from app.schemas.game import PlaythroughInfo
from app.schemas.assignment import BonusAssignmentResponse
class DisputeCreate(BaseModel): class DisputeCreate(BaseModel):
@@ -63,11 +69,15 @@ class DisputeResponse(BaseModel):
class AssignmentDetailResponse(BaseModel): class AssignmentDetailResponse(BaseModel):
"""Detailed assignment information with proofs and dispute""" """Detailed assignment information with proofs and dispute"""
id: int id: int
challenge: ChallengeResponse challenge: ChallengeResponse | None # None for playthrough
game: GameShort | None = None # For playthrough
is_playthrough: bool = False
playthrough_info: dict | None = None # For playthrough (description, points, proof_type, proof_hint)
participant: UserPublic participant: UserPublic
status: str status: str
proof_url: str | None # External URL (YouTube, etc.) proof_url: str | None # External URL (YouTube, etc.)
proof_image_url: str | None # Uploaded file URL proof_image_url: str | None # Uploaded file URL (legacy, for backward compatibility)
proof_files: list[ProofFileResponse] = [] # Multiple uploaded files
proof_comment: str | None proof_comment: str | None
points_earned: int points_earned: int
streak_at_completion: int | None streak_at_completion: int | None
@@ -75,6 +85,7 @@ class AssignmentDetailResponse(BaseModel):
completed_at: datetime | None completed_at: datetime | None
can_dispute: bool # True if <24h since completion and not own assignment can_dispute: bool # True if <24h since completion and not own assignment
dispute: DisputeResponse | None dispute: DisputeResponse | None
bonus_challenges: list[dict] | None = None # For playthrough
class Config: class Config:
from_attributes = True from_attributes = True
@@ -83,7 +94,11 @@ class AssignmentDetailResponse(BaseModel):
class ReturnedAssignmentResponse(BaseModel): class ReturnedAssignmentResponse(BaseModel):
"""Returned assignment that needs to be redone""" """Returned assignment that needs to be redone"""
id: int id: int
challenge: ChallengeResponse challenge: ChallengeResponse | None = None # For challenge assignments
is_playthrough: bool = False
game_id: int | None = None # For playthrough assignments
game_title: str | None = None
game_cover_url: str | None = None
original_completed_at: datetime original_completed_at: datetime
dispute_reason: str dispute_reason: str

View File

@@ -1,6 +1,9 @@
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, Field, HttpUrl from typing import Self
from pydantic import BaseModel, Field, model_validator
from app.models.game import GameType
from app.models.challenge import ProofType
from app.schemas.user import UserPublic from app.schemas.user import UserPublic
@@ -13,17 +16,48 @@ class GameBase(BaseModel):
class GameCreate(GameBase): class GameCreate(GameBase):
cover_url: str | None = None cover_url: str | None = None
# Тип игры
game_type: GameType = GameType.CHALLENGES
# Поля для типа "Прохождение"
playthrough_points: int | None = Field(None, ge=1, le=1000)
playthrough_description: str | None = None
playthrough_proof_type: ProofType | None = None
playthrough_proof_hint: str | None = None
@model_validator(mode='after')
def validate_playthrough_fields(self) -> Self:
if self.game_type == GameType.PLAYTHROUGH:
if self.playthrough_points is None:
raise ValueError('playthrough_points обязателен для типа "Прохождение"')
if self.playthrough_description is None:
raise ValueError('playthrough_description обязателен для типа "Прохождение"')
if self.playthrough_proof_type is None:
raise ValueError('playthrough_proof_type обязателен для типа "Прохождение"')
return self
class GameUpdate(BaseModel): class GameUpdate(BaseModel):
title: str | None = Field(None, min_length=1, max_length=100) title: str | None = Field(None, min_length=1, max_length=100)
download_url: str | None = None download_url: str | None = None
genre: str | None = None genre: str | None = None
# Тип игры
game_type: GameType | None = None
# Поля для типа "Прохождение"
playthrough_points: int | None = Field(None, ge=1, le=1000)
playthrough_description: str | None = None
playthrough_proof_type: ProofType | None = None
playthrough_proof_hint: str | None = None
class GameShort(BaseModel): class GameShort(BaseModel):
id: int id: int
title: str title: str
cover_url: str | None = None cover_url: str | None = None
download_url: str
game_type: str = "challenges"
class Config: class Config:
from_attributes = True from_attributes = True
@@ -38,5 +72,22 @@ class GameResponse(GameBase):
challenges_count: int = 0 challenges_count: int = 0
created_at: datetime created_at: datetime
# Тип игры
game_type: str = "challenges"
# Поля для типа "Прохождение"
playthrough_points: int | None = None
playthrough_description: str | None = None
playthrough_proof_type: str | None = None
playthrough_proof_hint: str | None = None
class Config: class Config:
from_attributes = True from_attributes = True
class PlaythroughInfo(BaseModel):
"""Информация о прохождении для игр типа playthrough"""
description: str
points: int
proof_type: str
proof_hint: str | None = None

View File

@@ -49,6 +49,7 @@ class MarathonResponse(MarathonBase):
is_public: bool is_public: bool
game_proposal_mode: str game_proposal_mode: str
auto_events_enabled: bool auto_events_enabled: bool
cover_url: str | None
start_date: datetime | None start_date: datetime | None
end_date: datetime | None end_date: datetime | None
participants_count: int participants_count: int
@@ -69,6 +70,7 @@ class MarathonListItem(BaseModel):
title: str title: str
status: str status: str
is_public: bool is_public: bool
cover_url: str | None
participants_count: int participants_count: int
start_date: datetime | None start_date: datetime | None
end_date: datetime | None end_date: datetime | None
@@ -87,6 +89,7 @@ class MarathonPublicInfo(BaseModel):
title: str title: str
description: str | None description: str | None
status: str status: str
cover_url: str | None
participants_count: int participants_count: int
creator_nickname: str creator_nickname: str

View File

@@ -1,5 +1,5 @@
""" """
Dispute Scheduler for automatic dispute resolution after 24 hours. Dispute Scheduler - marks disputes as pending admin review after 24 hours.
""" """
import asyncio import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -8,16 +8,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.models import Dispute, DisputeStatus, Assignment, AssignmentStatus from app.models import Dispute, DisputeStatus, Assignment, AssignmentStatus
from app.services.disputes import dispute_service from app.services.telegram_notifier import telegram_notifier
# Configuration # Configuration
CHECK_INTERVAL_SECONDS = 300 # Check every 5 minutes CHECK_INTERVAL_SECONDS = 300 # Check every 5 minutes
DISPUTE_WINDOW_HOURS = 24 # Disputes auto-resolve after 24 hours DISPUTE_WINDOW_HOURS = 24 # Disputes need admin decision after 24 hours
class DisputeScheduler: class DisputeScheduler:
"""Background scheduler for automatic dispute resolution.""" """Background scheduler that marks expired disputes for admin review."""
def __init__(self): def __init__(self):
self._running = False self._running = False
@@ -55,7 +55,7 @@ class DisputeScheduler:
await asyncio.sleep(CHECK_INTERVAL_SECONDS) await asyncio.sleep(CHECK_INTERVAL_SECONDS)
async def _process_expired_disputes(self, db: AsyncSession) -> None: async def _process_expired_disputes(self, db: AsyncSession) -> None:
"""Process and resolve expired disputes.""" """Mark expired disputes as pending admin review."""
cutoff_time = datetime.utcnow() - timedelta(hours=DISPUTE_WINDOW_HOURS) cutoff_time = datetime.utcnow() - timedelta(hours=DISPUTE_WINDOW_HOURS)
# Find all open disputes that have expired # Find all open disputes that have expired
@@ -63,7 +63,6 @@ class DisputeScheduler:
select(Dispute) select(Dispute)
.options( .options(
selectinload(Dispute.votes), selectinload(Dispute.votes),
selectinload(Dispute.assignment).selectinload(Assignment.participant),
) )
.where( .where(
Dispute.status == DisputeStatus.OPEN.value, Dispute.status == DisputeStatus.OPEN.value,
@@ -74,15 +73,25 @@ class DisputeScheduler:
for dispute in expired_disputes: for dispute in expired_disputes:
try: try:
result_status, votes_valid, votes_invalid = await dispute_service.resolve_dispute( # Count votes for logging
db, dispute.id 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)
# Mark as pending admin decision
dispute.status = DisputeStatus.PENDING_ADMIN.value
print( print(
f"[DisputeScheduler] Auto-resolved dispute {dispute.id}: " f"[DisputeScheduler] Dispute {dispute.id} marked as pending admin "
f"{result_status} (valid: {votes_valid}, invalid: {votes_invalid})" f"(recommendation: {'invalid' if votes_invalid > votes_valid else 'valid'}, "
f"votes: {votes_valid} valid, {votes_invalid} invalid)"
) )
except Exception as e: except Exception as e:
print(f"[DisputeScheduler] Failed to resolve dispute {dispute.id}: {e}") print(f"[DisputeScheduler] Failed to process dispute {dispute.id}: {e}")
if expired_disputes:
await db.commit()
# Notify admins about pending disputes
await telegram_notifier.notify_admin_disputes_pending(db, len(expired_disputes))
# Global scheduler instance # Global scheduler instance

View File

@@ -23,12 +23,15 @@ class DisputeService:
Returns: Returns:
Tuple of (result_status, votes_valid, votes_invalid) Tuple of (result_status, votes_valid, votes_invalid)
""" """
# Get dispute with votes and assignment from app.models import BonusAssignment, BonusAssignmentStatus
# Get dispute with votes, assignment and bonus_assignment
result = await db.execute( result = await db.execute(
select(Dispute) select(Dispute)
.options( .options(
selectinload(Dispute.votes), selectinload(Dispute.votes),
selectinload(Dispute.assignment).selectinload(Assignment.participant), selectinload(Dispute.assignment).selectinload(Assignment.participant),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant),
) )
.where(Dispute.id == dispute_id) .where(Dispute.id == dispute_id)
) )
@@ -46,8 +49,11 @@ class DisputeService:
# Determine result: tie goes to the accused (valid) # Determine result: tie goes to the accused (valid)
if votes_invalid > votes_valid: if votes_invalid > votes_valid:
# Proof is invalid - mark assignment as RETURNED # Proof is invalid
result_status = DisputeStatus.RESOLVED_INVALID.value result_status = DisputeStatus.RESOLVED_INVALID.value
if dispute.bonus_assignment_id:
await self._handle_invalid_bonus_proof(db, dispute)
else:
await self._handle_invalid_proof(db, dispute) await self._handle_invalid_proof(db, dispute)
else: else:
# Proof is valid (or tie) # Proof is valid (or tie)
@@ -60,7 +66,11 @@ class DisputeService:
await db.commit() await db.commit()
# Send Telegram notification about dispute resolution # Send Telegram notification about dispute resolution
await self._notify_dispute_resolved(db, dispute, result_status == DisputeStatus.RESOLVED_INVALID.value) is_invalid = result_status == DisputeStatus.RESOLVED_INVALID.value
if dispute.bonus_assignment_id:
await self._notify_bonus_dispute_resolved(db, dispute, is_invalid)
else:
await self._notify_dispute_resolved(db, dispute, is_invalid)
return result_status, votes_valid, votes_invalid return result_status, votes_valid, votes_invalid
@@ -72,12 +82,13 @@ class DisputeService:
) -> None: ) -> None:
"""Send notification about dispute resolution to the assignment owner.""" """Send notification about dispute resolution to the assignment owner."""
try: try:
# Get assignment with challenge and marathon info # Get assignment with challenge/game and marathon info
result = await db.execute( result = await db.execute(
select(Assignment) select(Assignment)
.options( .options(
selectinload(Assignment.participant), selectinload(Assignment.participant),
selectinload(Assignment.challenge).selectinload(Challenge.game) selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough
) )
.where(Assignment.id == dispute.assignment_id) .where(Assignment.id == dispute.assignment_id)
) )
@@ -86,12 +97,19 @@ class DisputeService:
return return
participant = assignment.participant participant = assignment.participant
# Get title and marathon_id based on assignment type
if assignment.is_playthrough:
title = f"Прохождение: {assignment.game.title}"
marathon_id = assignment.game.marathon_id
else:
challenge = assignment.challenge challenge = assignment.challenge
game = challenge.game if challenge else None title = challenge.title if challenge else "Unknown"
marathon_id = challenge.game.marathon_id if challenge and challenge.game else 0
# Get marathon # Get marathon
result = await db.execute( result = await db.execute(
select(Marathon).where(Marathon.id == game.marathon_id if game else 0) select(Marathon).where(Marathon.id == marathon_id)
) )
marathon = result.scalar_one_or_none() marathon = result.scalar_one_or_none()
@@ -100,12 +118,86 @@ class DisputeService:
db, db,
user_id=participant.user_id, user_id=participant.user_id,
marathon_title=marathon.title, marathon_title=marathon.title,
challenge_title=challenge.title if challenge else "Unknown", challenge_title=title,
is_valid=is_valid is_valid=is_valid
) )
except Exception as e: except Exception as e:
print(f"[DisputeService] Failed to send notification: {e}") print(f"[DisputeService] Failed to send notification: {e}")
async def _notify_bonus_dispute_resolved(
self,
db: AsyncSession,
dispute: Dispute,
is_invalid: bool
) -> None:
"""Send notification about bonus dispute resolution to the assignment owner."""
try:
bonus_assignment = dispute.bonus_assignment
main_assignment = bonus_assignment.main_assignment
participant = main_assignment.participant
# Get marathon info
result = await db.execute(
select(Game).where(Game.id == main_assignment.game_id)
)
game = result.scalar_one_or_none()
if not game:
return
result = await db.execute(
select(Marathon).where(Marathon.id == game.marathon_id)
)
marathon = result.scalar_one_or_none()
# Get challenge title
result = await db.execute(
select(Challenge).where(Challenge.id == bonus_assignment.challenge_id)
)
challenge = result.scalar_one_or_none()
title = f"Бонус: {challenge.title}" if challenge else "Бонусный челлендж"
if marathon and participant:
await telegram_notifier.notify_dispute_resolved(
db,
user_id=participant.user_id,
marathon_title=marathon.title,
challenge_title=title,
is_valid=not is_invalid
)
except Exception as e:
print(f"[DisputeService] Failed to send bonus dispute notification: {e}")
async def _handle_invalid_bonus_proof(self, db: AsyncSession, dispute: Dispute) -> None:
"""
Handle the case when bonus proof is determined to be invalid.
- Reset bonus assignment to PENDING
- If main playthrough was already completed, subtract bonus points from participant
"""
from app.models import BonusAssignment, BonusAssignmentStatus, AssignmentStatus
bonus_assignment = dispute.bonus_assignment
main_assignment = bonus_assignment.main_assignment
participant = main_assignment.participant
# If main playthrough was already completed, we need to subtract the bonus points
if main_assignment.status == AssignmentStatus.COMPLETED.value:
points_to_subtract = bonus_assignment.points_earned
participant.total_points = max(0, participant.total_points - points_to_subtract)
# Also reduce the points_earned on the main assignment
main_assignment.points_earned = max(0, main_assignment.points_earned - points_to_subtract)
print(f"[DisputeService] Subtracted {points_to_subtract} points from participant {participant.id}")
# Reset bonus assignment
bonus_assignment.status = BonusAssignmentStatus.PENDING.value
bonus_assignment.proof_path = None
bonus_assignment.proof_url = None
bonus_assignment.proof_comment = None
bonus_assignment.points_earned = 0
bonus_assignment.completed_at = None
print(f"[DisputeService] Bonus assignment {bonus_assignment.id} reset to PENDING due to invalid dispute")
async def _handle_invalid_proof(self, db: AsyncSession, dispute: Dispute) -> None: async def _handle_invalid_proof(self, db: AsyncSession, dispute: Dispute) -> None:
""" """
Handle the case when proof is determined to be invalid. Handle the case when proof is determined to be invalid.
@@ -113,7 +205,10 @@ class DisputeService:
- Mark assignment as RETURNED - Mark assignment as RETURNED
- Subtract points from participant - Subtract points from participant
- Reset streak if it was affected - Reset streak if it was affected
- For playthrough: also reset bonus assignments
""" """
from app.models import BonusAssignment, BonusAssignmentStatus
assignment = dispute.assignment assignment = dispute.assignment
participant = assignment.participant participant = assignment.participant
@@ -121,22 +216,45 @@ class DisputeService:
points_to_subtract = assignment.points_earned points_to_subtract = assignment.points_earned
participant.total_points = max(0, participant.total_points - points_to_subtract) participant.total_points = max(0, participant.total_points - points_to_subtract)
# Reset streak - the completion was invalid so streak should be broken
participant.current_streak = 0
# Reset assignment # Reset assignment
assignment.status = AssignmentStatus.RETURNED.value assignment.status = AssignmentStatus.RETURNED.value
assignment.points_earned = 0 assignment.points_earned = 0
# Keep proof data so it can be reviewed # Keep proof data so it can be reviewed
# For playthrough: reset all bonus assignments
if assignment.is_playthrough:
result = await db.execute(
select(BonusAssignment).where(BonusAssignment.main_assignment_id == assignment.id)
)
bonus_assignments = result.scalars().all()
for ba in bonus_assignments:
ba.status = BonusAssignmentStatus.PENDING.value
ba.proof_path = None
ba.proof_url = None
ba.proof_comment = None
ba.points_earned = 0
ba.completed_at = None
print(f"[DisputeService] Reset {len(bonus_assignments)} bonus assignments for playthrough {assignment.id}")
print(f"[DisputeService] Assignment {assignment.id} marked as RETURNED, " print(f"[DisputeService] Assignment {assignment.id} marked as RETURNED, "
f"subtracted {points_to_subtract} points from participant {participant.id}") 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]: async def get_pending_disputes(self, db: AsyncSession, older_than_hours: int = 24) -> list[Dispute]:
"""Get all open disputes older than specified hours""" """Get all open disputes (both regular and bonus) older than specified hours"""
from datetime import timedelta from datetime import timedelta
from app.models import BonusAssignment
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours) cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
result = await db.execute( result = await db.execute(
select(Dispute) select(Dispute)
.options(
selectinload(Dispute.assignment),
selectinload(Dispute.bonus_assignment),
)
.where( .where(
Dispute.status == DisputeStatus.OPEN.value, Dispute.status == DisputeStatus.OPEN.value,
Dispute.created_at < cutoff_time, Dispute.created_at < cutoff_time,

View File

@@ -15,7 +15,7 @@ from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
StorageFolder = Literal["avatars", "covers", "proofs"] StorageFolder = Literal["avatars", "covers", "proofs", "bonus_proofs"]
class StorageService: class StorageService:

View File

@@ -276,6 +276,79 @@ class TelegramNotifier:
) )
return await self.notify_user(db, user_id, message) return await self.notify_user(db, user_id, message)
async def notify_challenge_approved(
self,
db: AsyncSession,
user_id: int,
marathon_title: str,
game_title: str,
challenge_title: str
) -> bool:
"""Notify user that their proposed challenge was approved."""
message = (
f"✅ <b>Твой челлендж одобрен!</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Игра: {game_title}\n"
f"Задание: {challenge_title}\n\n"
f"Теперь оно доступно для всех участников."
)
return await self.notify_user(db, user_id, message)
async def notify_challenge_rejected(
self,
db: AsyncSession,
user_id: int,
marathon_title: str,
game_title: str,
challenge_title: str
) -> bool:
"""Notify user that their proposed challenge was rejected."""
message = (
f"❌ <b>Твой челлендж отклонён</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Игра: {game_title}\n"
f"Задание: {challenge_title}\n\n"
f"Ты можешь предложить другой челлендж."
)
return await self.notify_user(db, user_id, message)
async def notify_admin_disputes_pending(
self,
db: AsyncSession,
count: int
) -> bool:
"""Notify admin about disputes waiting for decision."""
if not settings.TELEGRAM_ADMIN_ID:
logger.warning("[Notify] No TELEGRAM_ADMIN_ID configured")
return False
admin_url = f"{settings.FRONTEND_URL}/admin/disputes"
use_inline_button = admin_url.startswith("https://")
if use_inline_button:
message = (
f"⚠️ <b>{count} оспаривани{'е' if count == 1 else 'й'} ожида{'ет' if count == 1 else 'ют'} решения</b>\n\n"
f"Голосование завершено, требуется ваше решение."
)
reply_markup = {
"inline_keyboard": [[
{"text": "Открыть оспаривания", "url": admin_url}
]]
}
else:
message = (
f"⚠️ <b>{count} оспаривани{'е' if count == 1 else 'й'} ожида{'ет' if count == 1 else 'ют'} решения</b>\n\n"
f"Голосование завершено, требуется ваше решение.\n\n"
f"🔗 {admin_url}"
)
reply_markup = None
return await self.send_message(
int(settings.TELEGRAM_ADMIN_ID),
message,
reply_markup=reply_markup
)
# Global instance # Global instance
telegram_notifier = TelegramNotifier() telegram_notifier = TelegramNotifier()

View File

@@ -79,6 +79,8 @@ def create_backup() -> tuple[str, bytes]:
config.DB_NAME, config.DB_NAME,
"--no-owner", "--no-owner",
"--no-acl", "--no-acl",
"--clean", # Add DROP commands before CREATE
"--if-exists", # Use IF EXISTS with DROP commands
"-F", "-F",
"p", # plain SQL format "p", # plain SQL format
] ]

View File

@@ -4,7 +4,8 @@ Restore PostgreSQL database from S3 backup.
Usage: Usage:
python restore.py - List available backups python restore.py - List available backups
python restore.py <filename> - Restore from specific backup python restore.py <filename> - Restore from backup (cleans DB first)
python restore.py <filename> --no-clean - Restore without cleaning DB first
""" """
import gzip import gzip
import os import os
@@ -62,7 +63,48 @@ def list_backups(s3_client) -> list[tuple[str, float, str]]:
return [] return []
def restore_backup(s3_client, filename: str) -> None: def clean_database() -> None:
"""Drop and recreate public schema to clean the database."""
print("Cleaning database (dropping and recreating public schema)...")
env = os.environ.copy()
env["PGPASSWORD"] = config.DB_PASSWORD
# Drop and recreate public schema
clean_sql = b"""
DROP SCHEMA public CASCADE;
CREATE SCHEMA public;
GRANT ALL ON SCHEMA public TO public;
"""
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=clean_sql,
capture_output=True,
)
if result.returncode != 0:
stderr = result.stderr.decode()
if "ERROR" in stderr:
raise Exception(f"Database cleanup failed: {stderr}")
print("Database cleaned successfully!")
def restore_backup(s3_client, filename: str, clean_first: bool = True) -> None:
"""Download and restore backup.""" """Download and restore backup."""
key = f"{config.S3_BACKUP_PREFIX}{filename}" key = f"{config.S3_BACKUP_PREFIX}{filename}"
@@ -79,6 +121,10 @@ def restore_backup(s3_client, filename: str) -> None:
print("Decompressing...") print("Decompressing...")
sql_data = gzip.decompress(compressed_data) sql_data = gzip.decompress(compressed_data)
# Clean database before restore if requested
if clean_first:
clean_database()
print(f"Restoring to database {config.DB_NAME}...") print(f"Restoring to database {config.DB_NAME}...")
# Build psql command # Build psql command
@@ -124,20 +170,32 @@ def main() -> int:
s3_client = create_s3_client() s3_client = create_s3_client()
if len(sys.argv) < 2: # Parse arguments
args = sys.argv[1:]
clean_first = True
if "--no-clean" in args:
clean_first = False
args.remove("--no-clean")
if len(args) < 1:
# List available backups # List available backups
backups = list_backups(s3_client) backups = list_backups(s3_client)
if backups: if backups:
print(f"\nTo restore, run: python restore.py <filename>") print(f"\nTo restore, run: python restore.py <filename>")
print("Add --no-clean to skip database cleanup before restore")
else: else:
print("No backups found.") print("No backups found.")
return 0 return 0
filename = sys.argv[1] filename = args[0]
# Confirm restore # Confirm restore
print(f"WARNING: This will restore database from {filename}") print(f"WARNING: This will restore database from {filename}")
print("This may overwrite existing data!") if clean_first:
print("Database will be CLEANED (all existing data will be DELETED)!")
else:
print("Database will NOT be cleaned (may cause conflicts with existing data)")
print() print()
confirm = input("Type 'yes' to continue: ") confirm = input("Type 'yes' to continue: ")
@@ -147,7 +205,7 @@ def main() -> int:
return 0 return 0
try: try:
restore_backup(s3_client, filename) restore_backup(s3_client, filename, clean_first=clean_first)
return 0 return 0
except Exception as e: except Exception as e:
print(f"Restore failed: {e}") print(f"Restore failed: {e}")

View File

@@ -9,7 +9,7 @@ services:
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
ports: ports:
- "5432:5432" - "5433:5432"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U marathon"] test: ["CMD-SHELL", "pg_isready -U marathon"]
interval: 5s interval: 5s
@@ -27,9 +27,10 @@ services:
SECRET_KEY: ${SECRET_KEY:-change-me-in-production} SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
OPENAI_API_KEY: ${OPENAI_API_KEY} OPENAI_API_KEY: ${OPENAI_API_KEY}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-GameMarathonBot} TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-BCMarathonbot}
BOT_API_SECRET: ${BOT_API_SECRET:-} BOT_API_SECRET: ${BOT_API_SECRET:-}
DEBUG: ${DEBUG:-false} DEBUG: ${DEBUG:-false}
RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED:-true}
# S3 Storage # S3 Storage
S3_ENABLED: ${S3_ENABLED:-false} S3_ENABLED: ${S3_ENABLED:-false}
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-} S3_BUCKET_NAME: ${S3_BUCKET_NAME:-}
@@ -42,7 +43,7 @@ services:
- ./backend/uploads:/app/uploads - ./backend/uploads:/app/uploads
- ./backend/app:/app/app - ./backend/app:/app/app
ports: ports:
- "8000:8000" - "8002:8000"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -56,7 +57,7 @@ services:
VITE_API_URL: ${VITE_API_URL:-/api/v1} VITE_API_URL: ${VITE_API_URL:-/api/v1}
container_name: marathon-frontend container_name: marathon-frontend
ports: ports:
- "3000:80" - "3002:80"
depends_on: depends_on:
- backend - backend
restart: unless-stopped restart: unless-stopped

381
docs/disputes.md Normal file
View File

@@ -0,0 +1,381 @@
# Система оспаривания (Disputes)
Система оспаривания позволяет участникам марафона проверять доказательства (пруфы) выполненных заданий друг друга и голосовать за их валидность.
## Общий принцип работы
```
┌──────────────────────────────────────────────────────────────────────────┐
│ ЖИЗНЕННЫЙ ЦИКЛ ДИСПУТА │
└──────────────────────────────────────────────────────────────────────────┘
Участник A Участник B Все участники
выполняет задание замечает проблему голосуют
│ │ │
▼ ▼ ▼
┌───────────┐ 24 часа ┌───────────┐ 24 часа ┌───────────┐
│ Завершено │ ─────────────────▶ │ Оспорено │ ─────────────▶ │ Решено │
│ │ окно оспаривания │ (OPEN) │ голосование │ │
└───────────┘ └───────────┘ └───────────┘
│ │ │
│ │ ├──▶ VALID (пруф OK)
│ │ │ Задание остаётся
│ │ │
│ │ └──▶ INVALID (пруф не OK)
│ │ Задание возвращается
│ │
└──────────────────────────────────┘
Если не оспорено — задание засчитано
```
## Кто может оспаривать
| Условие | Можно оспорить? |
|---------|-----------------|
| Своё задание | ❌ Нельзя |
| Чужое задание (статус COMPLETED) | ✅ Можно (в течение 24 часов) |
| Чужое задание (статус ACTIVE/DROPPED) | ❌ Нельзя |
| Прошло более 24 часов с момента выполнения | ❌ Нельзя |
| Уже есть активный диспут на это задание | ❌ Нельзя |
## Типы оспариваемых заданий
### 1. Обычные челленджи
Можно оспорить выполнение любого челленджа. При признании пруфа невалидным:
- Задание переходит в статус `RETURNED`
- Очки снимаются с участника
- Участник должен переделать задание
### 2. Прохождения игр (Playthrough)
Основное задание прохождения можно оспорить. При признании невалидным:
- Основное задание переходит в статус `RETURNED`
- Очки снимаются
- **Все бонусные челленджи сбрасываются** в статус `PENDING`
### 3. Бонусные челленджи
Каждый бонусный челлендж можно оспорить **отдельно**. При признании невалидным:
- Только этот бонусный челлендж сбрасывается в `PENDING`
- Участник может переделать его
- Основное задание и другие бонусы не затрагиваются
**Важно:** Очки за бонусные челленджи начисляются только при завершении основного задания. Поэтому при оспаривании бонуса очки не снимаются — просто сбрасывается статус.
## Процесс голосования
### Создание диспута
1. Участник нажимает "Оспорить" на странице деталей задания
2. Вводит причину оспаривания (минимум 10 символов)
3. Создаётся диспут со статусом `OPEN`
4. Владельцу задания отправляется уведомление в Telegram
### Голосование
- **Любой участник марафона** может голосовать
- Два варианта: "Валидно" (пруф OK) или "Невалидно" (пруф не OK)
- Можно **изменить** свой голос до завершения голосования
- Голосование длится **24 часа** с момента создания диспута
### Комментарии
- Участники могут оставлять комментарии для обсуждения
- Комментарии помогают другим участникам принять решение
- Комментарии доступны только пока диспут открыт
## Разрешение диспута
### Автоматическое (по таймеру)
Через 24 часа диспут автоматически разрешается:
- Система подсчитывает голоса
- При равенстве голосов — **в пользу обвиняемого** (пруф валиден)
- Результат: `RESOLVED_VALID` или `RESOLVED_INVALID`
**Технически:** Фоновый планировщик (`DisputeScheduler`) проверяет истёкшие диспуты каждые 5 минут.
### Результаты
| Результат | Условие | Последствия |
|-----------|---------|-------------|
| `RESOLVED_VALID` | Голосов "валидно" ≥ голосов "невалидно" | Задание остаётся выполненным |
| `RESOLVED_INVALID` | Голосов "невалидно" > голосов "валидно" | Задание возвращается |
### Что происходит при INVALID
**Для обычного задания:**
1. Статус → `RETURNED`
2. Очки (`points_earned`) вычитаются из общего счёта участника
3. Пруфы сохраняются для истории
**Для прохождения:**
1. Основное задание → `RETURNED`
2. Очки вычитаются
3. Все бонусные челленджи сбрасываются:
- Статус → `PENDING`
- Пруфы удаляются
- Очки обнуляются
**Для бонусного челленджа:**
1. Только этот бонус → `PENDING`
2. Пруфы удаляются
3. Можно переделать
## API эндпоинты
### Создание диспута
```
POST /api/v1/assignments/{assignment_id}/dispute
POST /api/v1/bonus-assignments/{bonus_id}/dispute
Body: { "reason": "Описание проблемы с пруфом..." }
```
### Голосование
```
POST /api/v1/disputes/{dispute_id}/vote
Body: { "vote": true } // true = валидно, false = невалидно
```
### Комментарии
```
POST /api/v1/disputes/{dispute_id}/comments
Body: { "text": "Текст комментария" }
```
### Получение информации
```
GET /api/v1/assignments/{assignment_id}
// В ответе включено поле dispute с полной информацией:
{
"dispute": {
"id": 1,
"status": "open",
"reason": "...",
"votes_valid": 3,
"votes_invalid": 2,
"my_vote": true,
"expires_at": "2024-12-30T12:00:00Z",
"comments": [...],
"votes": [...]
}
}
```
## Структура базы данных
### Таблица `disputes`
| Поле | Тип | Описание |
|------|-----|----------|
| `id` | INT | PK |
| `assignment_id` | INT | FK → assignments (nullable для бонусов) |
| `bonus_assignment_id` | INT | FK → bonus_assignments (nullable для основных) |
| `raised_by_id` | INT | FK → users |
| `reason` | TEXT | Причина оспаривания |
| `status` | VARCHAR(20) | open / valid / invalid |
| `created_at` | DATETIME | Время создания |
| `resolved_at` | DATETIME | Время разрешения |
**Ограничение:** Либо `assignment_id`, либо `bonus_assignment_id` должен быть заполнен (не оба).
### Таблица `dispute_votes`
| Поле | Тип | Описание |
|------|-----|----------|
| `id` | INT | PK |
| `dispute_id` | INT | FK → disputes |
| `user_id` | INT | FK → users |
| `vote` | BOOLEAN | true = валидно, false = невалидно |
| `created_at` | DATETIME | Время голоса |
**Ограничение:** Один голос на участника (`UNIQUE dispute_id + user_id`).
### Таблица `dispute_comments`
| Поле | Тип | Описание |
|------|-----|----------|
| `id` | INT | PK |
| `dispute_id` | INT | FK → disputes |
| `user_id` | INT | FK → users |
| `text` | TEXT | Текст комментария |
| `created_at` | DATETIME | Время комментария |
## UI компоненты
### Кнопка "Оспорить"
Появляется на странице деталей задания (`/assignments/{id}`) если:
- Статус задания: `COMPLETED`
- Это не своё задание
- Прошло меньше 24 часов с момента выполнения
- Нет активного диспута
### Секция диспута
Показывается если есть активный или завершённый диспут:
- Статус (открыт / валиден / невалиден)
- Таймер до окончания (для открытых)
- Причина оспаривания
- Кнопки голосования с счётчиками
- Секция комментариев
### Для бонусных челленджей
На каждом бонусном челлендже:
- Маленькая кнопка "Оспорить" (если можно)
- Бейдж статуса диспута
- Компактное голосование прямо в карточке бонуса
## Уведомления
### Telegram уведомления
| Событие | Получатель | Сообщение |
|---------|------------|-----------|
| Создание диспута | Владелец задания | "Ваше задание X оспорено в марафоне Y" |
| Результат: валидно | Владелец задания | "Диспут по заданию X решён в вашу пользу" |
| Результат: невалидно | Владелец задания | "Диспут по заданию X решён не в вашу пользу, задание возвращено" |
## Конфигурация
```python
# backend/app/api/v1/assignments.py
DISPUTE_WINDOW_HOURS = 24 # Окно для создания диспута
# backend/app/services/dispute_scheduler.py
CHECK_INTERVAL_SECONDS = 300 # Проверка каждые 5 минут
DISPUTE_WINDOW_HOURS = 24 # Время голосования
```
## Пример сценария
### Сценарий 1: Успешное оспаривание
1. **Иван** выполняет челлендж "Пройти уровень без смертей"
2. **Иван** прикладывает скриншот финального экрана
3. **Петр** открывает детали задания и видит, что на скриншоте есть смерти
4. **Петр** нажимает "Оспорить" и пишет: "На скриншоте видно 3 смерти"
5. Участники марафона голосуют: 5 за "невалидно", 2 за "валидно"
6. Через 24 часа диспут закрывается как `RESOLVED_INVALID`
7. Задание Ивана возвращается, очки снимаются
8. Иван получает уведомление и должен переделать задание
### Сценарий 2: Оспаривание бонуса
1. **Анна** проходит игру и выполняет бонусный челлендж
2. **Сергей** замечает проблему с пруфом бонуса
3. **Сергей** оспаривает только бонусный челлендж
4. Голосование: 4 за "невалидно", 1 за "валидно"
5. Результат: бонус сбрасывается в `PENDING`
6. Основное задание Анны **не затронуто**
7. Анна может переделать бонус (пока основное задание активно)
## Ручное разрешение диспутов
Администраторы системы и организаторы марафонов могут вручную разрешать диспуты, не дожидаясь окончания 24-часового окна голосования.
### Кто может разрешать
| Роль | Доступ |
|------|--------|
| **Системный админ** | Все диспуты во всех марафонах (`/admin/disputes`) |
| **Организатор марафона** | Только диспуты в своём марафоне (секция "Оспаривания" на странице марафона) |
### Интерфейс для системных админов
**Путь:** `/admin/disputes`
- Отдельная страница в админ-панели
- Фильтры: "Открытые" / "Все"
- Показывает диспуты из всех марафонов
- Информация: марафон, задание, участник, кто оспорил, причина
- Счётчик голосов и время до истечения
- Кнопки "Валидно" / "Невалидно" для мгновенного решения
### Интерфейс для организаторов
**Путь:** На странице марафона (`/marathons/{id}`) → секция "Оспаривания"
- Доступна только организаторам активного марафона
- Показывает только диспуты текущего марафона
- Компактный вид с возможностью раскрытия
- Ссылка на страницу задания для детального просмотра
### API для ручного разрешения
**Системные админы:**
```
GET /api/v1/admin/disputes?status_filter=open|all
POST /api/v1/admin/disputes/{dispute_id}/resolve
Body: { "is_valid": true|false }
```
**Организаторы марафона:**
```
GET /api/v1/marathons/{marathon_id}/disputes?status_filter=open|all
POST /api/v1/marathons/{marathon_id}/disputes/{dispute_id}/resolve
Body: { "is_valid": true|false }
```
### Что происходит при ручном разрешении
Логика идентична автоматическому разрешению:
**При `is_valid: true`:**
- Диспут закрывается как `RESOLVED_VALID`
- Задание остаётся выполненным
- Участник получает уведомление
**При `is_valid: false`:**
- Диспут закрывается как `RESOLVED_INVALID`
- Задание возвращается, очки снимаются
- Участник получает уведомление
### Важно: логика снятия очков за бонусы
При отклонении бонусного диспута система проверяет статус основного прохождения:
```
┌─────────────────────────────────────────────────────────────────┐
│ БОНУС ПРИЗНАН НЕВАЛИДНЫМ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Основное прохождение Основное прохождение │
НЕ завершено? УЖЕ завершено? │
│ │ │ │
│ ▼ ▼ │
│ ┌───────────┐ ┌───────────┐ │
│ │ Просто │ │ Вычитаем │ │
│ │ сбросить │ │ очки из │ │
│ │ бонус │ │ участника │ │
│ └───────────┘ └───────────┘ │
│ (очки ещё не (очки уже были │
│ были начислены) начислены при │
│ завершении прохождения) │
└─────────────────────────────────────────────────────────────────┘
```
**Почему так?** Очки за бонусные челленджи начисляются только в момент завершения основного прохождения (чтобы нельзя было получить очки за бонусы и потом дропнуть основное задание).
## Логирование действий
Ручное разрешение диспутов логируется в системе:
| Действие | Тип лога |
|----------|----------|
| Админ подтвердил пруф | `DISPUTE_RESOLVE_VALID` |
| Админ отклонил пруф | `DISPUTE_RESOLVE_INVALID` |
Логи доступны в `/admin/logs` для аудита действий администраторов.

242
docs/game-types.md Normal file
View File

@@ -0,0 +1,242 @@
# Система типов игр
## Обзор
В системе существует два типа игр, определяющих логику выдачи заданий:
| Тип | Значение | Описание |
|-----|----------|----------|
| **Челленджи** | `challenges` | При спине выдаётся один случайный челлендж из списка |
| **Прохождение** | `playthrough` | Нужно пройти игру целиком, челленджи становятся бонусными |
---
## Модели данных
### Game
```
game_type: str # "challenges" | "playthrough"
playthrough_points: int? # Очки за прохождение (только для playthrough)
playthrough_description: str? # Описание задания
playthrough_proof_type: str? # Тип пруфа: screenshot/video/steam
playthrough_proof_hint: str? # Подсказка для пруфа
```
### Assignment
```
challenge_id: int? # ID челленджа (для challenges)
game_id: int? # ID игры (для playthrough)
is_playthrough: bool # True если это прохождение
```
### BonusAssignment
```
main_assignment_id: int # Ссылка на основное задание (playthrough)
challenge_id: int # ID бонусного челленджа
status: str # "pending" | "completed"
proof_path: str? # Путь к файлу пруфа
proof_url: str? # URL пруфа
proof_comment: str? # Комментарий со ссылкой
points_earned: int # Заработанные очки
```
---
## Логика спина
### Тип "Челленджи" (challenges)
```
1. Выбрать случайную игру из доступных
2. Отфильтровать уже выполненные челленджи этой игры
3. Выбрать случайный невыполненный челлендж
4. Создать Assignment с challenge_id
```
**Игра исключается из спина**, если все её челленджи выполнены.
### Тип "Прохождение" (playthrough)
```
1. Выбрать случайную игру из доступных
2. Создать Assignment с game_id и is_playthrough=True
3. Создать BonusAssignment для каждого челленджа игры
4. События (Jackpot, Golden Hour и т.д.) ИГНОРИРУЮТСЯ
```
**Игра исключается из спина**, если есть Assignment со статусом COMPLETED или DROPPED.
---
## Завершение заданий
### Челлендж (challenges)
```
POST /marathons/{id}/complete-assignment
```
1. Загрузить пруф (файл или комментарий)
2. Начисляются очки челленджа × модификатор события
3. Увеличивается серия участника
4. Статус → COMPLETED
### Прохождение (playthrough)
```
POST /marathons/{id}/complete-assignment
```
1. Загрузить пруф прохождения
2. Начисляются очки за прохождение (`playthrough_points`)
3. Бонусные очки добавляются из completed BonusAssignments
4. Увеличивается серия участника
5. Все pending BonusAssignments удаляются (больше нельзя выполнить)
6. Статус → COMPLETED
### Бонусный челлендж
```
POST /marathons/{id}/assignments/{assignment_id}/bonus/{challenge_id}/complete
```
1. Доступно только пока основное задание ACTIVE
2. Загрузить пруф бонусного челленджа
3. BonusAssignment.status → COMPLETED
4. Очки накапливаются в BonusAssignment.points_earned
5. **Очки НЕ добавляются сразу** — добавятся при завершении основного задания
**Исключение:** Если main assignment уже COMPLETED (перепрохождение после диспута), очки добавляются сразу.
---
## Фильтрация игр для спина
### Функция `get_available_games_for_participant`
```python
for game in approved_games:
if game.game_type == "playthrough":
# Исключить если есть COMPLETED или DROPPED assignment
if has_finished_playthrough(participant, game):
continue
else: # challenges
# Исключить если ВСЕ челленджи выполнены
if all_challenges_completed(participant, game):
continue
available.append(game)
```
---
## Система очков
### Челлендж
```
base_points = challenge.points
modifier = event_modifier (если есть активное событие)
total = base_points × modifier
```
### Прохождение
```
base_points = game.playthrough_points
bonus_points = sum(bonus.points_earned for bonus in completed_bonuses)
total = base_points + bonus_points
```
**События НЕ влияют на очки за прохождение.**
---
## Дроп задания
### Челлендж
- Штраф в очках (зависит от настроек марафона)
- Серия обнуляется
- Игра остаётся доступной (можно получить другой челлендж)
### Прохождение
- Штраф в очках
- Серия обнуляется
- **Игра исключается из спина навсегда**
- Все BonusAssignments удаляются
---
## Диспуты
### Оспаривание прохождения
Если диспут признан недействительным:
1. Assignment → RETURNED
2. Вычитаются все очки (прохождение + бонусы)
3. Серия обнуляется
4. Все BonusAssignments сбрасываются в PENDING
### Оспаривание бонуса
Если диспут признан недействительным:
1. BonusAssignment → PENDING
2. Вычитаются очки бонуса
3. Proof данные очищаются
4. Можно попробовать выполнить заново
---
## API эндпоинты
| Метод | Путь | Описание |
|-------|------|----------|
| POST | `/marathons/{id}/spin` | Крутить колесо |
| POST | `/marathons/{id}/complete-assignment` | Завершить основное задание |
| POST | `/marathons/{id}/assignments/{id}/bonus/{challenge_id}/complete` | Завершить бонус |
| GET | `/marathons/{id}/available-games` | Список доступных игр |
| GET | `/marathons/{id}/available-games-count` | Количество доступных игр |
---
## Схема работы
```
┌─────────────────────────────────────────────────────────────────┐
│ СПИН │
└─────────────────────────────────────────────────────────────────┘
┌───────────────┴───────────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ PLAYTHROUGH │ │ CHALLENGES │
└─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Assignment │ │ Assignment │
│ game_id = X │ │ challenge_id │
│ is_playthrough │ │ = X │
└─────────────────┘ └─────────────────┘
│ │
▼ │
┌─────────────────┐ │
│ BonusAssignment │ │
× N (по числу │ │
│ челленджей) │ │
└─────────────────┘ │
│ │
├───────────────────────────────┤
▼ ▼
┌─────────────────────────────────────────────────┐
│ COMPLETE │
│ • Загрузка пруфа │
│ • Начисление очков │
│ • Увеличение серии │
└─────────────────────────────────────────────────┘
```

906
docs/tz-game-types.md Normal file
View File

@@ -0,0 +1,906 @@
# ТЗ: Типы игр "Прохождение" и "Челленджи"
## Описание задачи
Добавить систему типов для игр, которая определяет логику выпадения заданий при спине колеса.
### Два типа игр:
| Тип | Название | Поведение при выпадении |
|-----|----------|------------------------|
| `playthrough` | Прохождение | Основное задание — пройти игру. Челленджи становятся **дополнительными** заданиями |
| `challenges` | Челленджи | Выдаётся **случайный челлендж** из списка челленджей игры (текущее поведение) |
---
## Детальное описание логики
### Тип "Прохождение" (`playthrough`)
**При создании игры** с типом "Прохождение" указываются дополнительные поля:
- **Очки за прохождение** (`playthrough_points`) — количество очков за прохождение игры
- **Описание прохождения** (`playthrough_description`) — описание задания (например: "Пройти основной сюжет игры")
- **Тип пруфа** (`playthrough_proof_type`) — screenshot / video / steam
- **Подсказка для пруфа** (`playthrough_proof_hint`) — опционально (например: "Скриншот финальных титров")
**При выпадении игры** с типом "Прохождение":
1. **Основное задание**: Пройти игру (очки и описание берутся из полей игры)
2. **Дополнительные задания**: Все челленджи игры становятся **опциональными** бонусными заданиями
3. **Пруфы**:
- Требуется **отдельный пруф на прохождение** игры (тип из `playthrough_proof_type`)
- Для каждого бонусного челленджа **тоже требуется пруф** (по типу челленджа)
- **Прикрепление файла не обязательно** — можно отправить только комментарий со ссылкой на видео
4. **Система очков**:
- За основное прохождение — `playthrough_points` (указанные при создании)
- За каждый выполненный доп. челлендж — очки челленджа
5. **Завершение**: Задание считается выполненным после прохождения основной игры. Доп. челленджи **не обязательны** — можно выполнять параллельно или игнорировать
### Тип "Челленджи" (`challenges`)
При выпадении игры с типом "Челленджи":
1. Выбирается **один случайный челлендж** из списка челленджей игры
2. Участник выполняет только этот челлендж
3. Логика остаётся **без изменений** (текущее поведение системы)
---
### Фильтрация игр при спине
При выборе игры для спина необходимо исключать уже пройденные/дропнутые игры:
| Тип игры | Условие исключения из спина |
|----------|----------------------------|
| `playthrough` | Игра **исключается**, если участник **завершил ИЛИ дропнул** прохождение этой игры |
| `challenges` | Игра **исключается**, только если участник выполнил **все** челленджи этой игры |
**Логика:**
```
Для каждой игры в марафоне:
ЕСЛИ game_type == "playthrough":
Проверить: есть ли Assignment с is_playthrough=True для этой игры
со статусом COMPLETED или DROPPED?
Если да → исключить игру
ЕСЛИ game_type == "challenges":
Получить все челленджи игры
Получить все завершённые Assignment участника для этих челленджей
Если количество завершённых == количество челленджей → исключить игру
```
**Важно:** Если все игры исключены (всё пройдено), спин должен вернуть ошибку или специальный статус "Все игры пройдены!"
### Бонусные челленджи
Бонусные челленджи доступны **только пока основное задание активно**:
- После **завершения** прохождения — бонусные челленджи недоступны
- После **дропа** прохождения — бонусные челленджи недоступны
- Нельзя вернуться к бонусным челленджам позже
### Взаимодействие с событиями
**Все события игнорируются** при выпадении игры с типом `playthrough`:
| Событие | Поведение для `playthrough` |
|---------|----------------------------|
| **JACKPOT** (x3 за hard) | Игнорируется |
| **GAME_CHOICE** (выбор из 3) | Игнорируется |
| **GOLDEN_HOUR** (x1.5) | Игнорируется |
| **DOUBLE_RISK** (x0.5, бесплатный дроп) | Игнорируется |
| **COMMON_ENEMY** | Игнорируется |
| **SWAP** | Игнорируется |
Игрок получает стандартные очки `playthrough_points` без модификаторов.
---
## Изменения в Backend
### 1. Модель Game (`backend/app/models/game.py`)
Добавить поля для типа игры и прохождения:
```python
class GameType(str, Enum):
PLAYTHROUGH = "playthrough" # Прохождение
CHALLENGES = "challenges" # Челленджи
class Game(Base):
# ... существующие поля ...
# Тип игры
game_type: Mapped[str] = mapped_column(
String(20),
default=GameType.CHALLENGES.value,
nullable=False
)
# Поля для типа "Прохождение" (nullable, заполняются только для playthrough)
playthrough_points: Mapped[int | None] = mapped_column(
Integer,
nullable=True
)
playthrough_description: Mapped[str | None] = mapped_column(
Text,
nullable=True
)
playthrough_proof_type: Mapped[str | None] = mapped_column(
String(20), # screenshot, video, steam
nullable=True
)
playthrough_proof_hint: Mapped[str | None] = mapped_column(
Text,
nullable=True
)
```
### 2. Схемы Pydantic (`backend/app/schemas/`)
Обновить схемы для Game:
```python
# schemas/game.py
class GameType(str, Enum):
PLAYTHROUGH = "playthrough"
CHALLENGES = "challenges"
class GameCreate(BaseModel):
# ... существующие поля ...
game_type: GameType = GameType.CHALLENGES
# Поля для типа "Прохождение"
playthrough_points: int | None = None
playthrough_description: str | None = None
playthrough_proof_type: ProofType | None = None
playthrough_proof_hint: str | None = None
@model_validator(mode='after')
def validate_playthrough_fields(self) -> Self:
if self.game_type == GameType.PLAYTHROUGH:
if self.playthrough_points is None:
raise ValueError('playthrough_points обязателен для типа "Прохождение"')
if self.playthrough_description is None:
raise ValueError('playthrough_description обязателен для типа "Прохождение"')
if self.playthrough_proof_type is None:
raise ValueError('playthrough_proof_type обязателен для типа "Прохождение"')
if self.playthrough_points < 1 or self.playthrough_points > 500:
raise ValueError('playthrough_points должен быть от 1 до 500')
return self
class GameResponse(BaseModel):
# ... существующие поля ...
game_type: GameType
playthrough_points: int | None
playthrough_description: str | None
playthrough_proof_type: ProofType | None
playthrough_proof_hint: str | None
class GameUpdate(BaseModel):
"""Схема для редактирования игры"""
title: str | None = None
download_url: str | None = None
genre: str | None = None
game_type: GameType | None = None
playthrough_points: int | None = None
playthrough_description: str | None = None
playthrough_proof_type: ProofType | None = None
playthrough_proof_hint: str | None = None
@model_validator(mode='after')
def validate_playthrough_fields(self) -> Self:
# Валидация только если меняем на playthrough
if self.game_type == GameType.PLAYTHROUGH:
if self.playthrough_points is not None:
if self.playthrough_points < 1 or self.playthrough_points > 500:
raise ValueError('playthrough_points должен быть от 1 до 500')
return self
```
### 3. Миграция Alembic
```python
# Новая миграция
def upgrade():
# Тип игры
op.add_column('games', sa.Column(
'game_type',
sa.String(20),
nullable=False,
server_default='challenges'
))
# Поля для прохождения
op.add_column('games', sa.Column(
'playthrough_points',
sa.Integer(),
nullable=True
))
op.add_column('games', sa.Column(
'playthrough_description',
sa.Text(),
nullable=True
))
op.add_column('games', sa.Column(
'playthrough_proof_type',
sa.String(20),
nullable=True
))
op.add_column('games', sa.Column(
'playthrough_proof_hint',
sa.Text(),
nullable=True
))
def downgrade():
op.drop_column('games', 'playthrough_proof_hint')
op.drop_column('games', 'playthrough_proof_type')
op.drop_column('games', 'playthrough_description')
op.drop_column('games', 'playthrough_points')
op.drop_column('games', 'game_type')
```
### 4. Логика спина (`backend/app/api/v1/wheel.py`)
Изменить функцию `spin_wheel`:
```python
async def get_available_games(
participant: Participant,
marathon_games: list[Game],
db: AsyncSession
) -> list[Game]:
"""Получить список игр, доступных для спина"""
available = []
for game in marathon_games:
if game.game_type == GameType.PLAYTHROUGH.value:
# Проверяем, прошёл ли участник эту игру
# Исключаем если COMPLETED или DROPPED
finished = await db.scalar(
select(Assignment)
.where(
Assignment.participant_id == participant.id,
Assignment.game_id == game.id,
Assignment.is_playthrough == True,
Assignment.status.in_([
AssignmentStatus.COMPLETED.value,
AssignmentStatus.DROPPED.value
])
)
)
if not finished:
available.append(game)
else: # GameType.CHALLENGES
# Проверяем, остались ли невыполненные челленджи
completed_challenge_ids = await db.scalars(
select(Assignment.challenge_id)
.where(
Assignment.participant_id == participant.id,
Assignment.challenge_id.in_([c.id for c in game.challenges]),
Assignment.status == AssignmentStatus.COMPLETED.value
)
)
completed_ids = set(completed_challenge_ids.all())
all_challenge_ids = {c.id for c in game.challenges}
if completed_ids != all_challenge_ids:
available.append(game)
return available
async def spin_wheel(...):
# Получаем доступные игры (исключаем пройденные)
available_games = await get_available_games(participant, marathon_games, db)
if not available_games:
raise HTTPException(
status_code=400,
detail="Все игры пройдены! Поздравляем!"
)
game = random.choice(available_games)
if game.game_type == GameType.PLAYTHROUGH.value:
# Для playthrough НЕ выбираем челлендж — основное задание это прохождение
# Данные берутся из полей игры: playthrough_points, playthrough_description
challenge = None # Или создаём виртуальный объект
# Все челленджи игры становятся дополнительными
bonus_challenges = list(game.challenges)
# Создаём Assignment с флагом is_playthrough=True
assignment = Assignment(
participant_id=participant.id,
challenge_id=None, # Нет привязки к челленджу
game_id=game.id, # Новое поле — привязка к игре
is_playthrough=True,
status=AssignmentStatus.ACTIVE,
# ...
)
else: # GameType.CHALLENGES
# Выбираем случайный НЕВЫПОЛНЕННЫЙ челлендж
completed_challenge_ids = await db.scalars(
select(Assignment.challenge_id)
.where(
Assignment.participant_id == participant.id,
Assignment.challenge_id.in_([c.id for c in game.challenges]),
Assignment.status == AssignmentStatus.COMPLETED.value
)
)
completed_ids = set(completed_challenge_ids.all())
available_challenges = [c for c in game.challenges if c.id not in completed_ids]
challenge = random.choice(available_challenges)
bonus_challenges = []
assignment = Assignment(
participant_id=participant.id,
challenge_id=challenge.id,
is_playthrough=False,
status=AssignmentStatus.ACTIVE,
# ...
)
# ... сохранение Assignment ...
```
### 5. Модель Assignment (`backend/app/models/assignment.py`)
Обновить модель для поддержки прохождений:
```python
class Assignment(Base):
# ... существующие поля ...
# Для прохождений: привязка к игре вместо челленджа
game_id: Mapped[int | None] = mapped_column(
ForeignKey("games.id"),
nullable=True
)
is_playthrough: Mapped[bool] = mapped_column(Boolean, default=False)
# Relationships
game: Mapped["Game"] = relationship(back_populates="playthrough_assignments")
# Отдельная таблица для бонусных челленджей
class BonusAssignment(Base):
__tablename__ = "bonus_assignments"
id: Mapped[int] = mapped_column(primary_key=True)
main_assignment_id: Mapped[int] = mapped_column(ForeignKey("assignments.id"))
challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id"))
status: Mapped[str] = mapped_column(String(20), default="pending") # pending, completed
proof_path: Mapped[str | None] = mapped_column(Text, nullable=True)
proof_url: Mapped[str | None] = mapped_column(Text, nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(nullable=True)
points_earned: Mapped[int] = mapped_column(Integer, default=0)
# Relationships
main_assignment: Mapped["Assignment"] = relationship(back_populates="bonus_assignments")
challenge: Mapped["Challenge"] = relationship()
```
### 6. API эндпоинты
Добавить/обновить эндпоинты:
```python
# Обновить ответ спина
class PlaythroughInfo(BaseModel):
"""Информация о прохождении (для playthrough игр)"""
description: str
points: int
class SpinResult(BaseModel):
assignment_id: int
game: GameResponse
challenge: ChallengeResponse | None # None для playthrough
is_playthrough: bool
playthrough_info: PlaythroughInfo | None # Заполняется для playthrough
bonus_challenges: list[ChallengeResponse] = [] # Для playthrough
can_drop: bool
drop_penalty: int
# Завершение бонусного челленджа
@router.post("/assignments/{assignment_id}/bonus/{challenge_id}/complete")
async def complete_bonus_challenge(
assignment_id: int,
challenge_id: int,
proof: ProofData,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
) -> BonusAssignmentResponse:
"""Завершить дополнительный челлендж для игры-прохождения"""
...
# Получение бонусных челленджей
@router.get("/assignments/{assignment_id}/bonus")
async def get_bonus_assignments(
assignment_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
) -> list[BonusAssignmentResponse]:
"""Получить список бонусных челленджей и их статус"""
...
# Получение количества доступных игр для спина
@router.get("/marathons/{marathon_id}/available-games-count")
async def get_available_games_count(
marathon_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
) -> dict:
"""
Получить количество игр, доступных для спина.
Возвращает: { "available": 5, "total": 10 }
"""
participant = await get_participant(...)
marathon_games = await get_marathon_games(...)
available = await get_available_games(participant, marathon_games, db)
return {
"available": len(available),
"total": len(marathon_games)
}
# Редактирование игры
@router.patch("/marathons/{marathon_id}/games/{game_id}")
async def update_game(
marathon_id: int,
game_id: int,
game_data: GameUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
) -> GameResponse:
"""
Редактировать игру.
Доступно только организатору марафона.
При смене типа на 'playthrough' необходимо указать playthrough_points и playthrough_description.
"""
# Проверка прав (организатор)
# Валидация: если меняем тип на playthrough, проверить что поля заполнены
# Обновление полей
...
```
---
## Изменения в Frontend
### 1. Типы (`frontend/src/types/index.ts`)
```typescript
export type GameType = 'playthrough' | 'challenges'
export interface Game {
// ... существующие поля ...
game_type: GameType
playthrough_points: number | null
playthrough_description: string | null
}
export interface PlaythroughInfo {
description: string
points: number
}
export interface SpinResult {
assignment_id: number
game: Game
challenge: Challenge | null // null для playthrough
is_playthrough: boolean
playthrough_info: PlaythroughInfo | null
bonus_challenges: Challenge[]
can_drop: boolean
drop_penalty: number
}
export interface BonusAssignment {
id: number
challenge: Challenge
status: 'pending' | 'completed'
proof_url: string | null
completed_at: string | null
points_earned: number
}
export interface GameUpdate {
title?: string
download_url?: string
genre?: string
game_type?: GameType
playthrough_points?: number
playthrough_description?: string
}
```
### 2. Форма добавления игры
Добавить выбор типа игры и условные поля:
```tsx
// components/AddGameForm.tsx
const [gameType, setGameType] = useState<GameType>('challenges')
const [playthroughPoints, setPlaythroughPoints] = useState<number>(100)
const [playthroughDescription, setPlaythroughDescription] = useState<string>('')
return (
<form>
{/* ... существующие поля ... */}
<Select
label="Тип игры"
value={gameType}
onChange={setGameType}
options={[
{ value: 'challenges', label: 'Челленджи' },
{ value: 'playthrough', label: 'Прохождение' }
]}
/>
{/* Поля только для типа "Прохождение" */}
{gameType === 'playthrough' && (
<>
<Input
type="number"
label="Очки за прохождение"
value={playthroughPoints}
onChange={setPlaythroughPoints}
min={1}
max={500}
required
/>
<Textarea
label="Описание прохождения"
value={playthroughDescription}
onChange={setPlaythroughDescription}
placeholder="Например: Пройти основной сюжет игры"
required
/>
</>
)}
</form>
)
```
### 3. Отображение результата спина
Для типа "Прохождение" показывать:
- Основное задание с описанием из `playthrough_info`
- Очки за прохождение
- Список дополнительных челленджей (опциональные)
```tsx
// components/SpinResult.tsx
{result.is_playthrough ? (
<PlaythroughCard
game={result.game}
info={result.playthrough_info}
bonusChallenges={result.bonus_challenges}
/>
) : (
<ChallengeCard challenge={result.challenge} />
)}
```
### 4. Карточка текущего задания
Для playthrough показывать прогресс по доп. челленджам:
```tsx
// components/CurrentAssignment.tsx
{assignment.is_playthrough && (
<div className="mt-4">
<h4>Дополнительные задания (опционально)</h4>
<BonusChallengesList
assignmentId={assignment.id}
challenges={assignment.bonus_challenges}
onComplete={handleBonusComplete}
/>
<p className="text-sm text-gray-500">
Выполнено: {completedCount} / {totalCount} (+{bonusPoints} очков)
</p>
</div>
)}
```
### 5. Форма завершения бонусного челленджа
```tsx
// components/BonusChallengeCompleteModal.tsx
<Modal>
<h3>Завершить челлендж: {challenge.title}</h3>
<p>{challenge.description}</p>
<p>Очки: +{challenge.points}</p>
<ProofUpload
proofType={challenge.proof_type}
onUpload={handleProofUpload}
/>
<Button onClick={handleComplete}>
Завершить (+{challenge.points} очков)
</Button>
</Modal>
```
### 6. Редактирование игры
Добавить модалку/страницу редактирования игры:
```tsx
// components/EditGameModal.tsx
interface EditGameModalProps {
game: Game
onSave: (data: GameUpdate) => void
onClose: () => void
}
const EditGameModal = ({ game, onSave, onClose }: EditGameModalProps) => {
const [title, setTitle] = useState(game.title)
const [downloadUrl, setDownloadUrl] = useState(game.download_url)
const [genre, setGenre] = useState(game.genre)
const [gameType, setGameType] = useState<GameType>(game.game_type)
const [playthroughPoints, setPlaythroughPoints] = useState(game.playthrough_points ?? 100)
const [playthroughDescription, setPlaythroughDescription] = useState(game.playthrough_description ?? '')
const handleSubmit = () => {
const data: GameUpdate = {
title,
download_url: downloadUrl,
genre,
game_type: gameType,
...(gameType === 'playthrough' && {
playthrough_points: playthroughPoints,
playthrough_description: playthroughDescription,
}),
}
onSave(data)
}
return (
<Modal onClose={onClose}>
<h2>Редактирование игры</h2>
<Input label="Название" value={title} onChange={setTitle} />
<Input label="Ссылка на скачивание" value={downloadUrl} onChange={setDownloadUrl} />
<Input label="Жанр" value={genre} onChange={setGenre} />
<Select
label="Тип игры"
value={gameType}
onChange={setGameType}
options={[
{ value: 'challenges', label: 'Челленджи' },
{ value: 'playthrough', label: 'Прохождение' }
]}
/>
{gameType === 'playthrough' && (
<>
<Input
type="number"
label="Очки за прохождение"
value={playthroughPoints}
onChange={setPlaythroughPoints}
min={1}
max={500}
/>
<Textarea
label="Описание прохождения"
value={playthroughDescription}
onChange={setPlaythroughDescription}
/>
</>
)}
<div className="flex gap-2">
<Button variant="secondary" onClick={onClose}>Отмена</Button>
<Button onClick={handleSubmit}>Сохранить</Button>
</div>
</Modal>
)
}
```
### 7. Кнопка редактирования в списке игр
```tsx
// components/GameCard.tsx (или GamesList)
{isOrganizer && (
<Button
variant="ghost"
size="sm"
onClick={() => setEditingGame(game)}
>
Редактировать
</Button>
)}
```
### 8. Счётчик доступных игр
Отображать количество игр, которые ещё могут выпасть при спине:
```tsx
// components/AvailableGamesCounter.tsx
interface AvailableGamesCounterProps {
available: number
total: number
}
const AvailableGamesCounter = ({ available, total }: AvailableGamesCounterProps) => {
const allCompleted = available === 0
return (
<div className="text-sm text-gray-500">
{allCompleted ? (
<span className="text-green-600 font-medium">
Все игры пройдены!
</span>
) : (
<span>
Доступно игр: <strong>{available}</strong> из {total}
</span>
)}
</div>
)
}
// Использование на странице марафона / рядом с колесом
<AvailableGamesCounter available={gamesCount.available} total={gamesCount.total} />
```
---
## Уточнённые требования
| Вопрос | Решение |
|--------|---------|
| Очки за прохождение | Устанавливаются при создании игры (поле `playthrough_points`) |
| Обязательность доп. челленджей | **Не обязательны** — можно завершить задание без них |
| Пруф на прохождение | Тип указывается при создании (`playthrough_proof_type`) |
| Пруфы на бонусные челленджи | **Требуются** — по типу челленджа (screenshot/video/steam) |
| Прикрепление файла | **Не обязательно** — можно отправить комментарий со ссылкой |
| Миграция существующих игр | Тип по умолчанию: `challenges` |
| Дроп игры (playthrough) | Дропнутая игра **не выпадает** повторно |
| Бонусные челленджи после завершения | **Недоступны** — только пока задание активно |
| Счётчик игр | Показывать "Доступно игр: X из Y" |
| События для playthrough | **Все игнорируются** — стандартные очки без модификаторов |
---
## План реализации
### Этап 1: Backend (модели и миграции) ✅
- [x] Добавить enum `GameType` в `backend/app/models/game.py`
- [x] Добавить поля `game_type`, `playthrough_points`, `playthrough_description`, `playthrough_proof_type`, `playthrough_proof_hint` в модель Game
- [x] Создать модель `BonusAssignment` в `backend/app/models/bonus_assignment.py`
- [x] Обновить модель `Assignment` — добавить `game_id`, `is_playthrough`
- [x] Создать миграцию Alembic (`020_add_game_types.py`)
### Этап 2: Backend (схемы и API) ✅
- [x] Обновить Pydantic схемы для Game (`GameCreate`, `GameResponse`)
- [x] Добавить схему `GameUpdate` с валидацией
- [x] Обновить API создания игры
- [x] Добавить API редактирования игры (`PATCH /games/{id}`)
- [x] Добавить API счётчика игр (`GET /available-games-count`)
- [x] Добавить схемы для `BonusAssignment`, `PlaythroughInfo`
- [x] Добавить эндпоинты для бонусных челленджей
### Этап 3: Backend (логика спина) ✅
- [x] Добавить функцию `get_available_games()` для фильтрации пройденных игр
- [x] Обновить логику `spin_wheel` для обработки типов
- [x] Для типа `challenges` — выбирать только невыполненные челленджи
- [x] Обработать случай "Все игры пройдены"
- [x] Обновить ответ SpinResult
- [x] Обновить логику завершения задания для playthrough
- [x] Добавить логику завершения бонусных челленджей
- [x] Игнорирование событий для playthrough
### Этап 4: Frontend (типы и формы) ✅
- [x] Обновить типы TypeScript (`Game`, `SpinResult`, `BonusAssignment`, `GameUpdate`, `AvailableGamesCount`)
- [x] Добавить выбор типа в форму создания игры
- [x] Добавить условные поля "Очки", "Описание", "Тип пруфа", "Подсказка" для типа "Прохождение"
- [x] Добавить API метод `gamesApi.update()` и `gamesApi.getAvailableGamesCount()`
- [x] Добавить API методы для бонусных челленджей
### Этап 5: Frontend (UI) ✅
- [x] Обновить отображение результата спина для playthrough
- [x] Обновить карточку текущего задания (PlayPage)
- [x] Показ бонусных челленджей со статусами
- [x] Бейдж "Прохождение" на карточках игр в лобби
- [x] Поддержка пруфа через комментарий для playthrough
### Этап 6: Тестирование
- [ ] Тестирование миграции на существующих данных
- [ ] Проверка создания игр обоих типов
- [ ] Проверка редактирования игр (смена типа, обновление полей)
- [ ] Проверка спина для playthrough и challenges
- [ ] Проверка фильтрации пройденных игр (playthrough не выпадает повторно)
- [ ] Проверка фильтрации челленджей (выпадают только невыполненные)
- [ ] Проверка состояния "Все игры пройдены"
- [ ] Проверка завершения основного и бонусных заданий
---
## Схема работы
### Создание игры
```
┌─────────────────────────────────────────────────────────────────┐
│ СОЗДАНИЕ ИГРЫ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────┐
│ Выбор типа │
└─────────────────┘
┌───────────────┴───────────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ "Прохождение" │ │ "Челленджи" │
│ │ │ │
│ Доп. поля: │ │ Стандартные │
│ • Очки │ │ поля │
│ • Описание │ │ │
└─────────────────┘ └─────────────────┘
```
### Спин колеса
```
┌─────────────────────────────────────────────────────────────────┐
│ СПИН КОЛЕСА │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────┐
│ Выбор игры │
│ (random) │
└─────────────────┘
┌───────────────┴───────────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ game_type = │ │ game_type = │
│ "playthrough" │ │ "challenges" │
└─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Основное: │ │ Случайный │
│ playthrough_ │ │ челлендж │
│ description │ │ │
│ │ │ (текущая │
│ Очки: │ │ логика) │
│ playthrough_ │ │ │
│ points │ │ │
│ │ │ │
│ Доп. задания: │ │ │
Все челленджи │ │ │
│ (опционально) │ │ │
└─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Пруф: │ │ Пруф: │
На прохождение │ │ По типу │
│ игры │ │ челленджа │
└─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Очки: │ │ Очки: │
│ + За прохождение│ │ + За челлендж │
│ + Бонус за доп. │ │ │
│ челленджи │ │ │
└─────────────────┘ └─────────────────┘
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -1,6 +1,8 @@
import { useEffect } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom' import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { ToastContainer, ConfirmModal } from '@/components/ui' import { ToastContainer, ConfirmModal } from '@/components/ui'
import { BannedScreen } from '@/components/BannedScreen'
// Layout // Layout
import { Layout } from '@/components/layout/Layout' import { Layout } from '@/components/layout/Layout'
@@ -19,10 +21,23 @@ import { InvitePage } from '@/pages/InvitePage'
import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage' import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage'
import { ProfilePage } from '@/pages/ProfilePage' import { ProfilePage } from '@/pages/ProfilePage'
import { UserProfilePage } from '@/pages/UserProfilePage' import { UserProfilePage } from '@/pages/UserProfilePage'
import { StaticContentPage } from '@/pages/StaticContentPage'
import { NotFoundPage } from '@/pages/NotFoundPage' import { NotFoundPage } from '@/pages/NotFoundPage'
import { TeapotPage } from '@/pages/TeapotPage' import { TeapotPage } from '@/pages/TeapotPage'
import { ServerErrorPage } from '@/pages/ServerErrorPage' import { ServerErrorPage } from '@/pages/ServerErrorPage'
// Admin Pages
import {
AdminLayout,
AdminDashboardPage,
AdminUsersPage,
AdminMarathonsPage,
AdminDisputesPage,
AdminLogsPage,
AdminBroadcastPage,
AdminContentPage,
} from '@/pages/admin'
// Protected route wrapper // Protected route wrapper
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated) const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
@@ -46,6 +61,24 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
} }
function App() { function App() {
const banInfo = useAuthStore((state) => state.banInfo)
const syncUser = useAuthStore((state) => state.syncUser)
// Sync user data with server on app load
useEffect(() => {
syncUser()
}, [syncUser])
// Show banned screen if user is banned (either authenticated or during login attempt)
if (banInfo) {
return (
<>
<ToastContainer />
<BannedScreen banInfo={banInfo} />
</>
)
}
return ( return (
<> <>
<ToastContainer /> <ToastContainer />
@@ -57,6 +90,11 @@ function App() {
{/* Public invite page */} {/* Public invite page */}
<Route path="invite/:code" element={<InvitePage />} /> <Route path="invite/:code" element={<InvitePage />} />
{/* Public static content pages */}
<Route path="terms" element={<StaticContentPage />} />
<Route path="privacy" element={<StaticContentPage />} />
<Route path="page/:key" element={<StaticContentPage />} />
<Route <Route
path="login" path="login"
element={ element={
@@ -159,6 +197,24 @@ function App() {
<Route path="500" element={<ServerErrorPage />} /> <Route path="500" element={<ServerErrorPage />} />
<Route path="error" element={<ServerErrorPage />} /> <Route path="error" element={<ServerErrorPage />} />
{/* Admin routes */}
<Route
path="admin"
element={
<ProtectedRoute>
<AdminLayout />
</ProtectedRoute>
}
>
<Route index element={<AdminDashboardPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="marathons" element={<AdminMarathonsPage />} />
<Route path="disputes" element={<AdminDisputesPage />} />
<Route path="logs" element={<AdminLogsPage />} />
<Route path="broadcast" element={<AdminBroadcastPage />} />
<Route path="content" element={<AdminContentPage />} />
</Route>
{/* 404 - must be last */} {/* 404 - must be last */}
<Route path="*" element={<NotFoundPage />} /> <Route path="*" element={<NotFoundPage />} />
</Route> </Route>

View File

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

View File

@@ -1,5 +1,11 @@
import client from './client' import client from './client'
import type { AssignmentDetail, Dispute, DisputeComment, ReturnedAssignment } from '@/types' import type { AssignmentDetail, Dispute, DisputeComment, ReturnedAssignment, BonusAssignment } from '@/types'
export interface BonusCompleteResult {
bonus_assignment_id: number
points_earned: number
total_bonus_points: number
}
export const assignmentsApi = { export const assignmentsApi = {
// Get detailed assignment info with proofs and dispute // Get detailed assignment info with proofs and dispute
@@ -14,6 +20,12 @@ export const assignmentsApi = {
return response.data return response.data
}, },
// Create a dispute against a bonus assignment
createBonusDispute: async (bonusId: number, reason: string): Promise<Dispute> => {
const response = await client.post<Dispute>(`/bonus-assignments/${bonusId}/dispute`, { reason })
return response.data
},
// Add a comment to a dispute // Add a comment to a dispute
addComment: async (disputeId: number, text: string): Promise<DisputeComment> => { addComment: async (disputeId: number, text: string): Promise<DisputeComment> => {
const response = await client.post<DisputeComment>(`/disputes/${disputeId}/comments`, { text }) const response = await client.post<DisputeComment>(`/disputes/${disputeId}/comments`, { text })
@@ -44,4 +56,95 @@ export const assignmentsApi = {
type: isVideo ? 'video' : 'image', type: isVideo ? 'video' : 'image',
} }
}, },
// Get bonus assignments for a playthrough assignment
getBonusAssignments: async (assignmentId: number): Promise<BonusAssignment[]> => {
const response = await client.get<BonusAssignment[]>(`/assignments/${assignmentId}/bonus`)
return response.data
},
// Complete a bonus challenge
completeBonusAssignment: async (
assignmentId: number,
bonusId: number,
data: { proof_file?: File; proof_files?: File[]; proof_url?: string; comment?: string }
): Promise<BonusCompleteResult> => {
const formData = new FormData()
// Support both single file (legacy) and multiple files
if (data.proof_file) {
formData.append('proof_file', data.proof_file)
}
if (data.proof_files && data.proof_files.length > 0) {
data.proof_files.forEach(file => {
formData.append('proof_files', file)
})
}
if (data.proof_url) {
formData.append('proof_url', data.proof_url)
}
if (data.comment) {
formData.append('comment', data.comment)
}
const response = await client.post<BonusCompleteResult>(
`/assignments/${assignmentId}/bonus/${bonusId}/complete`,
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }
)
return response.data
},
// Get bonus proof media as blob URL (supports both images and videos)
getBonusProofMediaUrl: async (
assignmentId: number,
bonusId: number
): Promise<{ url: string; type: 'image' | 'video' }> => {
const response = await client.get(
`/assignments/${assignmentId}/bonus/${bonusId}/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',
}
},
// Get individual proof file media as blob URL (for multiple proofs support)
getProofFileMediaUrl: async (
assignmentId: number,
proofFileId: number
): Promise<{ url: string; type: 'image' | 'video' }> => {
const response = await client.get(
`/assignments/${assignmentId}/proof-files/${proofFileId}/media`,
{ responseType: 'blob' }
)
const contentType = response.headers['content-type'] || ''
const isVideo = contentType.startsWith('video/')
return {
url: URL.createObjectURL(response.data),
type: isVideo ? 'video' : 'image',
}
},
// Get individual bonus proof file media as blob URL (for multiple proofs support)
getBonusProofFileMediaUrl: async (
assignmentId: number,
bonusId: number,
proofFileId: number
): Promise<{ url: string; type: 'image' | 'video' }> => {
const response = await client.get(
`/assignments/${assignmentId}/bonus/${bonusId}/proof-files/${proofFileId}/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

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

View File

@@ -1,4 +1,5 @@
import axios, { AxiosError } from 'axios' import axios, { AxiosError } from 'axios'
import { useAuthStore, type BanInfo } from '@/store/auth'
const API_URL = import.meta.env.VITE_API_URL || '/api/v1' const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
@@ -18,16 +19,40 @@ client.interceptors.request.use((config) => {
return config return config
}) })
// Helper to check if detail is ban info object
function isBanInfo(detail: unknown): detail is BanInfo {
return (
typeof detail === 'object' &&
detail !== null &&
'banned_at' in detail &&
'reason' in detail
)
}
// Response interceptor to handle errors // Response interceptor to handle errors
client.interceptors.response.use( client.interceptors.response.use(
(response) => response, (response) => response,
(error: AxiosError<{ detail: string }>) => { (error: AxiosError<{ detail: string | BanInfo }>) => {
// Unauthorized - redirect to login // Unauthorized - redirect to login (but not for auth endpoints)
if (error.response?.status === 401) { if (error.response?.status === 401) {
const url = error.config?.url || ''
const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/register') || url.includes('/auth/2fa')
if (!isAuthEndpoint) {
localStorage.removeItem('token') localStorage.removeItem('token')
localStorage.removeItem('user') localStorage.removeItem('user')
window.location.href = '/login' window.location.href = '/login'
} }
}
// Forbidden - check if user is banned
if (error.response?.status === 403) {
const detail = error.response.data?.detail
if (isBanInfo(detail)) {
// User is banned - set ban info in store
useAuthStore.getState().setBanned(detail)
}
}
// Server error or network error - redirect to 500 page // Server error or network error - redirect to 500 page
if ( if (

View File

@@ -1,11 +1,28 @@
import client from './client' import client from './client'
import type { Game, GameStatus, Challenge, ChallengePreview, ChallengesPreviewResponse } from '@/types' import type { Game, GameStatus, GameType, ProofType, Challenge, ChallengePreview, ChallengesPreviewResponse, AvailableGamesCount } from '@/types'
export interface CreateGameData { export interface CreateGameData {
title: string title: string
download_url: string download_url: string
genre?: string genre?: string
cover_url?: string cover_url?: string
// Game type fields
game_type?: GameType
playthrough_points?: number
playthrough_description?: string
playthrough_proof_type?: ProofType
playthrough_proof_hint?: string
}
export interface UpdateGameData {
title?: string
download_url?: string
genre?: string
game_type?: GameType
playthrough_points?: number
playthrough_description?: string
playthrough_proof_type?: ProofType
playthrough_proof_hint?: string
} }
export interface CreateChallengeData { export interface CreateChallengeData {
@@ -45,6 +62,21 @@ export const gamesApi = {
await client.delete(`/games/${id}`) await client.delete(`/games/${id}`)
}, },
update: async (id: number, data: UpdateGameData): Promise<Game> => {
const response = await client.patch<Game>(`/games/${id}`, data)
return response.data
},
getAvailableGamesCount: async (marathonId: number): Promise<AvailableGamesCount> => {
const response = await client.get<AvailableGamesCount>(`/marathons/${marathonId}/available-games-count`)
return response.data
},
getAvailableGames: async (marathonId: number): Promise<Game[]> => {
const response = await client.get<Game[]>(`/marathons/${marathonId}/available-games`)
return response.data
},
approve: async (id: number): Promise<Game> => { approve: async (id: number): Promise<Game> => {
const response = await client.post<Game>(`/games/${id}/approve`) const response = await client.post<Game>(`/games/${id}/approve`)
return response.data return response.data
@@ -79,6 +111,11 @@ export const gamesApi = {
await client.delete(`/challenges/${id}`) await client.delete(`/challenges/${id}`)
}, },
updateChallenge: async (id: number, data: Partial<CreateChallengeData>): Promise<Challenge> => {
const response = await client.patch<Challenge>(`/challenges/${id}`, data)
return response.data
},
previewChallenges: async (marathonId: number, gameIds?: number[]): Promise<ChallengesPreviewResponse> => { previewChallenges: async (marathonId: number, gameIds?: number[]): Promise<ChallengesPreviewResponse> => {
const data = gameIds?.length ? { game_ids: gameIds } : undefined const data = gameIds?.length ? { game_ids: gameIds } : undefined
const response = await client.post<ChallengesPreviewResponse>(`/marathons/${marathonId}/preview-challenges`, data) const response = await client.post<ChallengesPreviewResponse>(`/marathons/${marathonId}/preview-challenges`, data)
@@ -89,4 +126,30 @@ export const gamesApi = {
const response = await client.post<{ message: string }>(`/marathons/${marathonId}/save-challenges`, { challenges }) const response = await client.post<{ message: string }>(`/marathons/${marathonId}/save-challenges`, { challenges })
return response.data return response.data
}, },
// Proposed challenges
proposeChallenge: async (gameId: number, data: CreateChallengeData): Promise<Challenge> => {
const response = await client.post<Challenge>(`/games/${gameId}/propose-challenge`, data)
return response.data
},
getProposedChallenges: async (marathonId: number): Promise<Challenge[]> => {
const response = await client.get<Challenge[]>(`/marathons/${marathonId}/proposed-challenges`)
return response.data
},
getMyProposedChallenges: async (marathonId: number): Promise<Challenge[]> => {
const response = await client.get<Challenge[]>(`/marathons/${marathonId}/my-proposed-challenges`)
return response.data
},
approveChallenge: async (id: number): Promise<Challenge> => {
const response = await client.patch<Challenge>(`/challenges/${id}/approve`)
return response.data
},
rejectChallenge: async (id: number): Promise<Challenge> => {
const response = await client.patch<Challenge>(`/challenges/${id}/reject`)
return response.data
},
} }

View File

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

View File

@@ -1,5 +1,5 @@
import client from './client' import client from './client'
import type { Marathon, MarathonListItem, MarathonPublicInfo, LeaderboardEntry, ParticipantWithUser, ParticipantRole, GameProposalMode } from '@/types' import type { Marathon, MarathonListItem, MarathonPublicInfo, MarathonUpdate, LeaderboardEntry, ParticipantWithUser, ParticipantRole, GameProposalMode, MarathonDispute } from '@/types'
export interface CreateMarathonData { export interface CreateMarathonData {
title: string title: string
@@ -10,6 +10,8 @@ export interface CreateMarathonData {
game_proposal_mode?: GameProposalMode game_proposal_mode?: GameProposalMode
} }
export type { MarathonUpdate }
export const marathonsApi = { export const marathonsApi = {
list: async (): Promise<MarathonListItem[]> => { list: async (): Promise<MarathonListItem[]> => {
const response = await client.get<MarathonListItem[]>('/marathons') const response = await client.get<MarathonListItem[]>('/marathons')
@@ -32,7 +34,7 @@ export const marathonsApi = {
return response.data return response.data
}, },
update: async (id: number, data: Partial<CreateMarathonData>): Promise<Marathon> => { update: async (id: number, data: MarathonUpdate): Promise<Marathon> => {
const response = await client.patch<Marathon>(`/marathons/${id}`, data) const response = await client.patch<Marathon>(`/marathons/${id}`, data)
return response.data return response.data
}, },
@@ -78,4 +80,36 @@ export const marathonsApi = {
const response = await client.get<LeaderboardEntry[]>(`/marathons/${id}/leaderboard`) const response = await client.get<LeaderboardEntry[]>(`/marathons/${id}/leaderboard`)
return response.data return response.data
}, },
uploadCover: async (id: number, file: File): Promise<Marathon> => {
const formData = new FormData()
formData.append('file', file)
const response = await client.post<Marathon>(`/marathons/${id}/cover`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data
},
deleteCover: async (id: number): Promise<Marathon> => {
const response = await client.delete<Marathon>(`/marathons/${id}/cover`)
return response.data
},
// Disputes management for organizers
listDisputes: async (id: number, status: 'open' | 'all' = 'open'): Promise<MarathonDispute[]> => {
const response = await client.get<MarathonDispute[]>(`/marathons/${id}/disputes`, {
params: { status_filter: status }
})
return response.data
},
resolveDispute: async (marathonId: number, disputeId: number, isValid: boolean): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>(
`/marathons/${marathonId}/disputes/${disputeId}/resolve`,
{ is_valid: isValid }
)
return response.data
},
} }

View File

@@ -14,12 +14,19 @@ export const wheelApi = {
complete: async ( complete: async (
assignmentId: number, assignmentId: number,
data: { proof_url?: string; comment?: string; proof_file?: File } data: { proof_url?: string; comment?: string; proof_file?: File; proof_files?: File[] }
): Promise<CompleteResult> => { ): Promise<CompleteResult> => {
const formData = new FormData() const formData = new FormData()
if (data.proof_url) formData.append('proof_url', data.proof_url) if (data.proof_url) formData.append('proof_url', data.proof_url)
if (data.comment) formData.append('comment', data.comment) if (data.comment) formData.append('comment', data.comment)
// Support both single file (legacy) and multiple files
if (data.proof_file) formData.append('proof_file', data.proof_file) if (data.proof_file) formData.append('proof_file', data.proof_file)
if (data.proof_files && data.proof_files.length > 0) {
data.proof_files.forEach(file => {
formData.append('proof_files', file)
})
}
const response = await client.post<CompleteResult>(`/assignments/${assignmentId}/complete`, formData, { const response = await client.post<CompleteResult>(`/assignments/${assignmentId}/complete`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },

View File

@@ -191,14 +191,15 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
const isEvent = isEventActivity(activity.type) const isEvent = isEventActivity(activity.type)
const { title, details, extra } = formatActivityMessage(activity) const { title, details, extra } = formatActivityMessage(activity)
// Get assignment_id and dispute status for complete activities // Get assignment_id, dispute status, and is_redo for complete activities
const activityData = activity.data as { assignment_id?: number; dispute_status?: string } | null const activityData = activity.data as { assignment_id?: number; dispute_status?: string; is_redo?: boolean } | null
const assignmentId = activity.type === 'complete' && activityData?.assignment_id const assignmentId = activity.type === 'complete' && activityData?.assignment_id
? activityData.assignment_id ? activityData.assignment_id
: null : null
const disputeStatus = activity.type === 'complete' && activityData?.dispute_status const disputeStatus = activity.type === 'complete' && activityData?.dispute_status
? activityData.dispute_status ? activityData.dispute_status
: null : null
const isRedo = activity.type === 'complete' && activityData?.is_redo === true
// Determine accent color based on activity type // Determine accent color based on activity type
const getAccentConfig = () => { const getAccentConfig = () => {
@@ -323,6 +324,12 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
<ExternalLink className="w-3 h-3" /> <ExternalLink className="w-3 h-3" />
Детали Детали
</button> </button>
{isRedo && (
<span className="text-xs text-purple-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-purple-500/10">
<Zap className="w-3 h-3" />
Перепрохождение
</span>
)}
{disputeStatus === 'open' && ( {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"> <span className="text-xs text-orange-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-orange-500/10">
<AlertTriangle className="w-3 h-3" /> <AlertTriangle className="w-3 h-3" />

View File

@@ -0,0 +1,78 @@
import { useState, useEffect } from 'react'
import { contentApi } from '@/api/admin'
import { Megaphone, X } from 'lucide-react'
const STORAGE_KEY = 'announcement_dismissed'
export function AnnouncementBanner() {
const [content, setContent] = useState<string | null>(null)
const [title, setTitle] = useState<string | null>(null)
const [updatedAt, setUpdatedAt] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const loadAnnouncement = async () => {
try {
const data = await contentApi.getPublicContent('announcement')
// Check if this announcement was already dismissed (by updated_at)
const dismissedAt = localStorage.getItem(STORAGE_KEY)
if (dismissedAt === data.updated_at) {
setContent(null)
} else {
setContent(data.content)
setTitle(data.title)
setUpdatedAt(data.updated_at)
}
} catch {
// No announcement or error - don't show
setContent(null)
} finally {
setIsLoading(false)
}
}
loadAnnouncement()
}, [])
const handleDismiss = () => {
if (updatedAt) {
// Store the updated_at to know which announcement was dismissed
// When admin updates announcement, updated_at changes and banner shows again
localStorage.setItem(STORAGE_KEY, updatedAt)
setContent(null)
}
}
if (isLoading || !content) {
return null
}
return (
<div className="relative rounded-xl overflow-hidden bg-gradient-to-r from-accent-500/20 via-purple-500/20 to-pink-500/20 border border-accent-500/30">
{/* Close button */}
<button
onClick={handleDismiss}
className="absolute top-3 right-3 p-1.5 text-white bg-dark-700/70 hover:bg-dark-600 rounded-lg transition-colors z-10"
title="Скрыть"
>
<X className="w-4 h-4" />
</button>
{/* Content */}
<div className="p-4 pr-12 flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-500/20 border border-accent-500/30 flex items-center justify-center flex-shrink-0">
<Megaphone className="w-5 h-5 text-accent-400" />
</div>
<div>
{title && (
<h3 className="font-semibold text-white mb-1">{title}</h3>
)}
<div
className="text-sm text-gray-300"
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,501 @@
import { useState, useRef, useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { marathonsApi } from '@/api'
import type { Marathon, GameProposalMode } from '@/types'
import { NeonButton, Input } from '@/components/ui'
import { useToast } from '@/store/toast'
import {
X, Camera, Trash2, Loader2, Save, Globe, Lock, Users, UserCog, Sparkles, Zap
} from 'lucide-react'
const settingsSchema = z.object({
title: z.string().min(1, 'Название обязательно').max(100, 'Максимум 100 символов'),
description: z.string().optional(),
start_date: z.string().min(1, 'Дата начала обязательна'),
is_public: z.boolean(),
game_proposal_mode: z.enum(['all_participants', 'organizer_only']),
auto_events_enabled: z.boolean(),
})
type SettingsForm = z.infer<typeof settingsSchema>
interface MarathonSettingsModalProps {
marathon: Marathon
isOpen: boolean
onClose: () => void
onUpdate: (marathon: Marathon) => void
}
export function MarathonSettingsModal({
marathon,
isOpen,
onClose,
onUpdate,
}: MarathonSettingsModalProps) {
const toast = useToast()
const fileInputRef = useRef<HTMLInputElement>(null)
const [isUploading, setIsUploading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [coverPreview, setCoverPreview] = useState<string | null>(null)
const {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors, isSubmitting, isDirty },
} = useForm<SettingsForm>({
resolver: zodResolver(settingsSchema),
defaultValues: {
title: marathon.title,
description: marathon.description || '',
start_date: marathon.start_date
? new Date(marathon.start_date).toISOString().slice(0, 16)
: '',
is_public: marathon.is_public,
game_proposal_mode: marathon.game_proposal_mode as GameProposalMode,
auto_events_enabled: marathon.auto_events_enabled,
},
})
const isPublic = watch('is_public')
const gameProposalMode = watch('game_proposal_mode')
const autoEventsEnabled = watch('auto_events_enabled')
// Reset form when marathon changes
useEffect(() => {
reset({
title: marathon.title,
description: marathon.description || '',
start_date: marathon.start_date
? new Date(marathon.start_date).toISOString().slice(0, 16)
: '',
is_public: marathon.is_public,
game_proposal_mode: marathon.game_proposal_mode as GameProposalMode,
auto_events_enabled: marathon.auto_events_enabled,
})
setCoverPreview(null)
}, [marathon, reset])
// Handle escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen, onClose])
// 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])
const onSubmit = async (data: SettingsForm) => {
try {
const updated = await marathonsApi.update(marathon.id, {
title: data.title,
description: data.description || undefined,
start_date: new Date(data.start_date).toISOString(),
is_public: data.is_public,
game_proposal_mode: data.game_proposal_mode,
auto_events_enabled: data.auto_events_enabled,
})
onUpdate(updated)
toast.success('Настройки сохранены')
onClose()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось сохранить настройки')
}
}
const handleCoverClick = () => {
fileInputRef.current?.click()
}
const handleCoverChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
toast.error('Файл должен быть изображением')
return
}
if (file.size > 5 * 1024 * 1024) {
toast.error('Максимальный размер файла 5 МБ')
return
}
// Show preview immediately
const previewUrl = URL.createObjectURL(file)
setCoverPreview(previewUrl)
setIsUploading(true)
try {
const updated = await marathonsApi.uploadCover(marathon.id, file)
onUpdate(updated)
toast.success('Обложка загружена')
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось загрузить обложку')
setCoverPreview(null)
} finally {
setIsUploading(false)
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
}
const handleDeleteCover = async () => {
setIsDeleting(true)
try {
const updated = await marathonsApi.deleteCover(marathon.id)
onUpdate(updated)
setCoverPreview(null)
toast.success('Обложка удалена')
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось удалить обложку')
} finally {
setIsDeleting(false)
}
}
if (!isOpen) return null
const displayCover = coverPreview || marathon.cover_url
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={onClose}
/>
{/* Modal */}
<div className="relative glass rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto animate-in zoom-in-95 fade-in duration-200 border border-dark-600 custom-scrollbar">
{/* Header */}
<div className="sticky top-0 z-10 bg-dark-800/95 backdrop-blur-sm border-b border-dark-600 px-6 py-4 flex items-center justify-between">
<h2 className="text-xl font-bold text-white">Настройки марафона</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-white transition-colors p-1"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-6">
{/* Cover Image */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Обложка марафона
</label>
<div className="relative group">
<button
type="button"
onClick={handleCoverClick}
disabled={isUploading || isDeleting}
className="relative w-full h-48 rounded-xl overflow-hidden bg-dark-700 border-2 border-dashed border-dark-500 hover:border-neon-500/50 transition-all"
>
{displayCover ? (
<img
src={displayCover}
alt="Обложка марафона"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-gray-500">
<Camera className="w-10 h-10 mb-2" />
<span className="text-sm">Нажмите для загрузки</span>
<span className="text-xs text-gray-600 mt-1">JPG, PNG до 5 МБ</span>
</div>
)}
{(isUploading || isDeleting) && (
<div className="absolute inset-0 bg-dark-900/80 flex items-center justify-center">
<Loader2 className="w-8 h-8 text-neon-500 animate-spin" />
</div>
)}
{displayCover && !isUploading && !isDeleting && (
<div className="absolute inset-0 bg-dark-900/70 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Camera className="w-8 h-8 text-neon-500" />
<span className="ml-2 text-white">Изменить</span>
</div>
)}
</button>
{displayCover && !isUploading && !isDeleting && (
<button
type="button"
onClick={handleDeleteCover}
className="absolute top-2 right-2 p-2 rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleCoverChange}
className="hidden"
/>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Title */}
<Input
label="Название"
placeholder="Введите название марафона"
error={errors.title?.message}
{...register('title')}
/>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Описание (необязательно)
</label>
<textarea
className="input min-h-[100px] resize-none w-full"
placeholder="Расскажите о вашем марафоне..."
{...register('description')}
/>
</div>
{/* Start date */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Дата начала
</label>
<input
type="datetime-local"
className="input w-full"
{...register('start_date')}
/>
{errors.start_date && (
<p className="text-red-400 text-xs mt-1">{errors.start_date.message}</p>
)}
</div>
{/* Marathon type */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Тип марафона
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setValue('is_public', false, { shouldDirty: true })}
className={`
relative p-4 rounded-xl border-2 transition-all duration-300 text-left group
${!isPublic
? 'border-neon-500/50 bg-neon-500/10 shadow-[0_0_14px_rgba(34,211,238,0.08)]'
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
>
<div className={`
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
${!isPublic ? 'bg-neon-500/20' : 'bg-dark-600'}
`}>
<Lock className={`w-5 h-5 ${!isPublic ? 'text-neon-400' : 'text-gray-400'}`} />
</div>
<div className={`font-semibold mb-1 ${!isPublic ? 'text-white' : 'text-gray-300'}`}>
Закрытый
</div>
<div className="text-xs text-gray-500">
Вход только по коду приглашения
</div>
{!isPublic && (
<div className="absolute top-3 right-3">
<Sparkles className="w-4 h-4 text-neon-400" />
</div>
)}
</button>
<button
type="button"
onClick={() => setValue('is_public', true, { shouldDirty: true })}
className={`
relative p-4 rounded-xl border-2 transition-all duration-300 text-left group
${isPublic
? 'border-accent-500/50 bg-accent-500/10 shadow-[0_0_14px_rgba(139,92,246,0.08)]'
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
>
<div className={`
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
${isPublic ? 'bg-accent-500/20' : 'bg-dark-600'}
`}>
<Globe className={`w-5 h-5 ${isPublic ? 'text-accent-400' : 'text-gray-400'}`} />
</div>
<div className={`font-semibold mb-1 ${isPublic ? 'text-white' : 'text-gray-300'}`}>
Открытый
</div>
<div className="text-xs text-gray-500">
Виден всем пользователям
</div>
{isPublic && (
<div className="absolute top-3 right-3">
<Sparkles className="w-4 h-4 text-accent-400" />
</div>
)}
</button>
</div>
</div>
{/* Game proposal mode */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Кто может предлагать игры
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setValue('game_proposal_mode', 'all_participants', { shouldDirty: true })}
className={`
relative p-4 rounded-xl border-2 transition-all duration-300 text-left
${gameProposalMode === 'all_participants'
? 'border-neon-500/50 bg-neon-500/10 shadow-[0_0_14px_rgba(34,211,238,0.08)]'
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
>
<div className={`
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
${gameProposalMode === 'all_participants' ? 'bg-neon-500/20' : 'bg-dark-600'}
`}>
<Users className={`w-5 h-5 ${gameProposalMode === 'all_participants' ? 'text-neon-400' : 'text-gray-400'}`} />
</div>
<div className={`font-semibold mb-1 ${gameProposalMode === 'all_participants' ? 'text-white' : 'text-gray-300'}`}>
Все участники
</div>
<div className="text-xs text-gray-500">
С модерацией организатором
</div>
{gameProposalMode === 'all_participants' && (
<div className="absolute top-3 right-3">
<Sparkles className="w-4 h-4 text-neon-400" />
</div>
)}
</button>
<button
type="button"
onClick={() => setValue('game_proposal_mode', 'organizer_only', { shouldDirty: true })}
className={`
relative p-4 rounded-xl border-2 transition-all duration-300 text-left
${gameProposalMode === 'organizer_only'
? 'border-accent-500/50 bg-accent-500/10 shadow-[0_0_14px_rgba(139,92,246,0.08)]'
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
>
<div className={`
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
${gameProposalMode === 'organizer_only' ? 'bg-accent-500/20' : 'bg-dark-600'}
`}>
<UserCog className={`w-5 h-5 ${gameProposalMode === 'organizer_only' ? 'text-accent-400' : 'text-gray-400'}`} />
</div>
<div className={`font-semibold mb-1 ${gameProposalMode === 'organizer_only' ? 'text-white' : 'text-gray-300'}`}>
Только организатор
</div>
<div className="text-xs text-gray-500">
Без модерации
</div>
{gameProposalMode === 'organizer_only' && (
<div className="absolute top-3 right-3">
<Sparkles className="w-4 h-4 text-accent-400" />
</div>
)}
</button>
</div>
</div>
{/* Auto events toggle */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Автоматические события
</label>
<button
type="button"
onClick={() => setValue('auto_events_enabled', !autoEventsEnabled, { shouldDirty: true })}
className={`
w-full p-4 rounded-xl border-2 transition-all duration-300 text-left flex items-center gap-4
${autoEventsEnabled
? 'border-yellow-500/50 bg-yellow-500/10'
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
}
`}
>
<div className={`
w-10 h-10 rounded-xl flex items-center justify-center transition-colors flex-shrink-0
${autoEventsEnabled ? 'bg-yellow-500/20' : 'bg-dark-600'}
`}>
<Zap className={`w-5 h-5 ${autoEventsEnabled ? 'text-yellow-400' : 'text-gray-400'}`} />
</div>
<div className="flex-1">
<div className={`font-semibold ${autoEventsEnabled ? 'text-white' : 'text-gray-300'}`}>
{autoEventsEnabled ? 'Включены' : 'Выключены'}
</div>
<div className="text-xs text-gray-500">
Случайные бонусные события во время марафона
</div>
</div>
<div className={`
w-12 h-7 rounded-full transition-colors relative flex-shrink-0
${autoEventsEnabled ? 'bg-yellow-500' : 'bg-dark-600'}
`}>
<div className={`
absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-transform
${autoEventsEnabled ? 'left-6' : 'left-1'}
`} />
</div>
</button>
</div>
{/* Actions */}
<div className="flex gap-3 pt-4 border-t border-dark-600">
<NeonButton
type="button"
variant="outline"
className="flex-1"
onClick={onClose}
>
Отмена
</NeonButton>
<NeonButton
type="submit"
className="flex-1"
isLoading={isSubmitting}
disabled={!isDirty}
icon={<Save className="w-4 h-4" />}
>
Сохранить
</NeonButton>
</div>
</form>
</div>
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useMemo } from 'react' import { useState, useCallback, useMemo, useEffect } from 'react'
import type { Game } from '@/types' import type { Game } from '@/types'
import { Gamepad2, Loader2 } from 'lucide-react' import { Gamepad2, Loader2 } from 'lucide-react'
@@ -9,27 +9,43 @@ interface SpinWheelProps {
disabled?: boolean disabled?: boolean
} }
const SPIN_DURATION = 5000 // ms const SPIN_DURATION = 6000 // ms - увеличено для более плавного замедления
const EXTRA_ROTATIONS = 5 const EXTRA_ROTATIONS = 7 // больше оборотов для эффекта инерции
// Цветовая палитра секторов // Пороги для адаптивного отображения
const TEXT_THRESHOLD = 16 // До 16 игр - показываем текст
const LINES_THRESHOLD = 40 // До 40 игр - показываем разделители
// Цветовая палитра секторов (расширенная для большего количества)
const SECTOR_COLORS = [ const SECTOR_COLORS = [
{ bg: '#0d9488', border: '#14b8a6' }, // teal { bg: '#0d9488', border: '#14b8a6' }, // teal
{ bg: '#7c3aed', border: '#8b5cf6' }, // violet { bg: '#7c3aed', border: '#8b5cf6' }, // violet
{ bg: '#0891b2', border: '#06b6d4' }, // cyan { bg: '#0891b2', border: '#06b6d4' }, // cyan
{ bg: '#c026d3', border: '#d946ef' }, // fuchsia { bg: '#c026d3', border: '#d946ef' }, // fuchsia
{ bg: '#059669', border: '#10b981' }, // emerald { bg: '#059669', border: '#10b981' }, // emerald
{ bg: '#7c2d12', border: '#ea580c' }, // orange { bg: '#ea580c', border: '#f97316' }, // orange
{ bg: '#1d4ed8', border: '#3b82f6' }, // blue { bg: '#1d4ed8', border: '#3b82f6' }, // blue
{ bg: '#be123c', border: '#e11d48' }, // rose { bg: '#be123c', border: '#e11d48' }, // rose
{ bg: '#4f46e5', border: '#6366f1' }, // indigo
{ bg: '#0284c7', border: '#0ea5e9' }, // sky
{ bg: '#9333ea', border: '#a855f7' }, // purple
{ bg: '#16a34a', border: '#22c55e' }, // green
] ]
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 [rotation, setRotation] = useState(0) const [rotation, setRotation] = useState(0)
const [displayedGame, setDisplayedGame] = useState<Game | null>(null)
const [spinStartTime, setSpinStartTime] = useState<number | null>(null)
const [startRotation, setStartRotation] = useState(0)
const [targetRotation, setTargetRotation] = useState(0)
// Размеры колеса // Определяем режим отображения
const wheelSize = 400 const showText = games.length <= TEXT_THRESHOLD
const showLines = games.length <= LINES_THRESHOLD
// Размеры колеса - увеличиваем для большого количества игр
const wheelSize = games.length > 50 ? 450 : games.length > 30 ? 420 : 400
const centerX = wheelSize / 2 const centerX = wheelSize / 2
const centerY = wheelSize / 2 const centerY = wheelSize / 2
const radius = wheelSize / 2 - 10 const radius = wheelSize / 2 - 10
@@ -102,11 +118,16 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
const fullRotations = EXTRA_ROTATIONS * 360 const fullRotations = EXTRA_ROTATIONS * 360
const finalAngle = fullRotations + (360 - targetSectorMidAngle) + baseRotation const finalAngle = fullRotations + (360 - targetSectorMidAngle) + baseRotation
setRotation(rotation + finalAngle) const newRotation = rotation + finalAngle
setStartRotation(rotation)
setTargetRotation(newRotation)
setSpinStartTime(Date.now())
setRotation(newRotation)
// Ждём окончания анимации // Ждём окончания анимации
setTimeout(() => { setTimeout(() => {
setIsSpinning(false) setIsSpinning(false)
setSpinStartTime(null)
onSpinComplete(resultGame) onSpinComplete(resultGame)
}, SPIN_DURATION) }, SPIN_DURATION)
}, [isSpinning, disabled, games, rotation, sectorAngle, onSpin, onSpinComplete]) }, [isSpinning, disabled, games, rotation, sectorAngle, onSpin, onSpinComplete])
@@ -117,13 +138,67 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
return text.slice(0, maxLength - 2) + '...' return text.slice(0, maxLength - 2) + '...'
} }
// Функция для вычисления игры под указателем по углу
const getGameAtAngle = useCallback((currentRotation: number) => {
if (games.length === 0) return null
const normalizedRotation = ((currentRotation % 360) + 360) % 360
const angleUnderPointer = (360 - normalizedRotation + 360) % 360
const sectorIndex = Math.floor(angleUnderPointer / sectorAngle) % games.length
return games[sectorIndex] || null
}, [games, sectorAngle])
// Вычисляем игру под указателем (статическое состояние)
const currentGameUnderPointer = useMemo(() => {
return getGameAtAngle(rotation)
}, [rotation, getGameAtAngle])
// Easing функция для имитации инерции - быстрый старт, долгое замедление
// Аппроксимирует CSS cubic-bezier(0.12, 0.9, 0.15, 1)
const easeOutExpo = useCallback((t: number): number => {
// Экспоненциальное замедление - очень быстро в начале, очень медленно в конце
return t === 1 ? 1 : 1 - Math.pow(2, -12 * t)
}, [])
// Отслеживаем позицию во время вращения
useEffect(() => {
if (!isSpinning || spinStartTime === null) {
// Когда не крутится - показываем текущую игру под указателем
if (currentGameUnderPointer) {
setDisplayedGame(currentGameUnderPointer)
}
return
}
const totalDelta = targetRotation - startRotation
const updateDisplayedGame = () => {
const elapsed = Date.now() - spinStartTime
const progress = Math.min(elapsed / SPIN_DURATION, 1)
const easedProgress = easeOutExpo(progress)
// Вычисляем текущий угол на основе прогресса анимации
const currentAngle = startRotation + (totalDelta * easedProgress)
const game = getGameAtAngle(currentAngle)
if (game) {
setDisplayedGame(game)
}
}
// Обновляем каждые 30мс для плавности
const interval = setInterval(updateDisplayedGame, 30)
updateDisplayedGame() // Сразу обновляем
return () => clearInterval(interval)
}, [isSpinning, spinStartTime, startRotation, targetRotation, getGameAtAngle, currentGameUnderPointer, easeOutExpo])
// Мемоизируем секторы для производительности // Мемоизируем секторы для производительности
const sectors = useMemo(() => { const sectors = useMemo(() => {
return games.map((game, index) => { return games.map((game, index) => {
const color = SECTOR_COLORS[index % SECTOR_COLORS.length] const color = SECTOR_COLORS[index % SECTOR_COLORS.length]
const path = createSectorPath(index, games.length) const path = createSectorPath(index, games.length)
const textPos = getTextPosition(index, games.length) const textPos = getTextPosition(index, games.length)
const maxTextLength = games.length > 8 ? 10 : games.length > 5 ? 14 : 18 const maxTextLength = games.length > 12 ? 8 : games.length > 8 ? 10 : games.length > 5 ? 14 : 18
return { game, color, path, textPos, maxTextLength } return { game, color, path, textPos, maxTextLength }
}) })
@@ -213,7 +288,8 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
transform: `rotate(${rotation}deg)`, transform: `rotate(${rotation}deg)`,
transitionProperty: isSpinning ? 'transform' : 'none', transitionProperty: isSpinning ? 'transform' : 'none',
transitionDuration: isSpinning ? `${SPIN_DURATION}ms` : '0ms', transitionDuration: isSpinning ? `${SPIN_DURATION}ms` : '0ms',
transitionTimingFunction: 'cubic-bezier(0.17, 0.67, 0.12, 0.99)', // Инерционное вращение: быстрый старт, долгое плавное замедление
transitionTimingFunction: 'cubic-bezier(0.12, 0.9, 0.15, 1)',
}} }}
> >
<defs> <defs>
@@ -230,12 +306,13 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
<path <path
d={path} d={path}
fill={color.bg} fill={color.bg}
stroke={color.border} stroke={showLines ? color.border : 'transparent'}
strokeWidth="2" strokeWidth={showLines ? "1" : "0"}
filter="url(#sectorShadow)" filter="url(#sectorShadow)"
/> />
{/* Текст названия игры */} {/* Текст названия игры - только для небольшого количества */}
{showText && (
<text <text
x={textPos.x} x={textPos.x}
y={textPos.y} y={textPos.y}
@@ -243,7 +320,7 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
textAnchor="middle" textAnchor="middle"
dominantBaseline="middle" dominantBaseline="middle"
fill="white" fill="white"
fontSize={games.length > 10 ? "10" : games.length > 6 ? "11" : "13"} fontSize={games.length > 12 ? "9" : games.length > 8 ? "10" : games.length > 6 ? "11" : "13"}
fontWeight="bold" fontWeight="bold"
style={{ style={{
textShadow: '0 1px 3px rgba(0,0,0,0.8)', textShadow: '0 1px 3px rgba(0,0,0,0.8)',
@@ -252,16 +329,19 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
> >
{truncateText(game.title, maxTextLength)} {truncateText(game.title, maxTextLength)}
</text> </text>
)}
{/* Разделительная линия */} {/* Разделительная линия - только для среднего количества */}
{showLines && (
<line <line
x1={centerX} x1={centerX}
y1={centerY} y1={centerY}
x2={centerX + radius * Math.cos((index * sectorAngle - 90) * Math.PI / 180)} x2={centerX + radius * Math.cos((index * sectorAngle - 90) * Math.PI / 180)}
y2={centerY + radius * Math.sin((index * sectorAngle - 90) * Math.PI / 180)} y2={centerY + radius * Math.sin((index * sectorAngle - 90) * Math.PI / 180)}
stroke="rgba(255,255,255,0.3)" stroke="rgba(255,255,255,0.2)"
strokeWidth="1" strokeWidth="1"
/> />
)}
</g> </g>
))} ))}
@@ -322,6 +402,21 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
)} )}
</div> </div>
{/* Название текущей игры (для большого количества) */}
{!showText && (
<div className="glass rounded-xl px-6 py-3 min-w-[280px] text-center">
<p className="text-xs text-gray-500 mb-1">
{games.length} игр в колесе
</p>
<p className={`
font-semibold transition-all duration-100 truncate max-w-[280px]
${isSpinning ? 'text-neon-400 animate-pulse' : 'text-white'}
`}>
{displayedGame?.title || 'Крутите колесо!'}
</p>
</div>
)}
{/* Подсказка */} {/* Подсказка */}
<p className={` <p className={`
text-sm transition-all duration-300 text-sm transition-all duration-300

View File

@@ -0,0 +1,100 @@
import { Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { contentApi } from '@/api/admin'
import { NeonButton } from '@/components/ui'
import { Bot, Bell, X } from 'lucide-react'
import { useState, useEffect } from 'react'
const STORAGE_KEY = 'telegram_banner_dismissed'
// Default content if not configured in admin
const DEFAULT_TITLE = 'Привяжите Telegram-бота'
const DEFAULT_DESCRIPTION = 'Получайте уведомления о событиях марафона, новых заданиях и результатах прямо в Telegram'
export function TelegramBotBanner() {
const user = useAuthStore((state) => state.user)
const [dismissed, setDismissed] = useState(() => {
return sessionStorage.getItem(STORAGE_KEY) === 'true'
})
const [title, setTitle] = useState(DEFAULT_TITLE)
const [description, setDescription] = useState(DEFAULT_DESCRIPTION)
useEffect(() => {
const loadContent = async () => {
try {
const data = await contentApi.getPublicContent('telegram_bot_info')
if (data.title) setTitle(data.title)
if (data.content) setDescription(data.content)
} catch {
// Use defaults if content not found
}
}
loadContent()
}, [])
const handleDismiss = () => {
sessionStorage.setItem(STORAGE_KEY, 'true')
setDismissed(true)
}
// Don't show if user already has Telegram linked or dismissed
if (user?.telegram_id || dismissed) {
return null
}
return (
<div className="relative rounded-2xl overflow-hidden">
{/* Background image */}
<div
className="absolute inset-0 bg-cover bg-center"
style={{ backgroundImage: 'url(/telegram_bot_banner.png)' }}
/>
{/* Overlay */}
<div className="absolute inset-0 bg-gradient-to-r from-dark-900/95 via-dark-900/80 to-dark-900/60" />
{/* Close button */}
<button
onClick={handleDismiss}
className="absolute top-3 right-3 p-1.5 text-white bg-dark-700/70 hover:bg-dark-600 rounded-lg transition-colors z-10"
title="Скрыть"
>
<X className="w-4 h-4" />
</button>
{/* Content */}
<div className="relative p-6 pr-12 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-[#2AABEE]/20 border border-[#2AABEE]/30 flex items-center justify-center flex-shrink-0">
<Bot className="w-6 h-6 text-[#2AABEE]" />
</div>
<div>
<h3 className="text-lg font-semibold text-white mb-1">
{title}
</h3>
<p className="text-gray-400 text-sm max-w-md">
{description}
</p>
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
<span className="flex items-center gap-1">
<Bell className="w-3 h-3" />
Мгновенные уведомления
</span>
<span className="flex items-center gap-1">
<Bot className="w-3 h-3" />
Удобное управление
</span>
</div>
</div>
</div>
<div className="ml-16 sm:ml-0">
<Link to="/profile">
<NeonButton color="neon" size="sm">
Привязать
</NeonButton>
</Link>
</div>
</div>
</div>
)
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom' import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { Gamepad2, LogOut, Trophy, User, Menu, X } from 'lucide-react' import { Gamepad2, LogOut, Trophy, User, Menu, X, Shield } from 'lucide-react'
import { TelegramLink } from '@/components/TelegramLink' import { TelegramLink } from '@/components/TelegramLink'
import { clsx } from 'clsx' import { clsx } from 'clsx'
@@ -74,6 +74,21 @@ export function Layout() {
<span>Марафоны</span> <span>Марафоны</span>
</Link> </Link>
{user?.role === 'admin' && (
<Link
to="/admin"
className={clsx(
'flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200',
location.pathname.startsWith('/admin')
? 'text-purple-400 bg-purple-500/10'
: 'text-gray-300 hover:text-white hover:bg-dark-700'
)}
>
<Shield className="w-5 h-5" />
<span>Админка</span>
</Link>
)}
<div className="flex items-center gap-3 ml-2 pl-4 border-l border-dark-600"> <div className="flex items-center gap-3 ml-2 pl-4 border-l border-dark-600">
<Link <Link
to="/profile" to="/profile"
@@ -144,6 +159,20 @@ export function Layout() {
<Trophy className="w-5 h-5" /> <Trophy className="w-5 h-5" />
<span>Марафоны</span> <span>Марафоны</span>
</Link> </Link>
{user?.role === 'admin' && (
<Link
to="/admin"
className={clsx(
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
location.pathname.startsWith('/admin')
? 'text-purple-400 bg-purple-500/10'
: 'text-gray-300 hover:text-white hover:bg-dark-700'
)}
>
<Shield className="w-5 h-5" />
<span>Админка</span>
</Link>
)}
<Link <Link
to="/profile" to="/profile"
className={clsx( className={clsx(
@@ -205,7 +234,13 @@ export function Layout() {
Игровой Марафон &copy; {new Date().getFullYear()} Игровой Марафон &copy; {new Date().getFullYear()}
</span> </span>
</div> </div>
<div className="flex items-center gap-4 text-sm text-gray-500"> <div className="flex items-center gap-6 text-sm">
<Link to="/terms" className="text-gray-500 hover:text-gray-300 transition-colors">
Правила
</Link>
<Link to="/privacy" className="text-gray-500 hover:text-gray-300 transition-colors">
Конфиденциальность
</Link>
<span className="text-neon-500/50">v1.0</span> <span className="text-neon-500/50">v1.0</span>
</div> </div>
</div> </div>

View File

@@ -67,18 +67,28 @@ export const NeonButton = forwardRef<HTMLButtonElement, NeonButtonProps>(
}, },
} }
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 = { const iconSizes = {
sm: 'w-4 h-4', sm: 'w-4 h-4',
md: 'w-5 h-5', md: 'w-5 h-5',
lg: 'w-6 h-6', lg: 'w-6 h-6',
} }
const isIconOnly = icon && !children
const sizeClassesWithText = {
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 sizeClassesIconOnly = {
sm: 'p-2 text-sm',
md: 'p-2.5 text-base',
lg: 'p-3 text-lg',
}
const sizeClasses = isIconOnly ? sizeClassesIconOnly : sizeClassesWithText
const colors = colorMap[color] const colors = colorMap[color]
return ( return (
@@ -118,13 +128,9 @@ export const NeonButton = forwardRef<HTMLButtonElement, NeonButtonProps>(
{...props} {...props}
> >
{isLoading && <Loader2 className={clsx(iconSizes[size], 'animate-spin')} />} {isLoading && <Loader2 className={clsx(iconSizes[size], 'animate-spin')} />}
{!isLoading && icon && iconPosition === 'left' && ( {!isLoading && icon && iconPosition === 'left' && icon}
<span className={iconSizes[size]}>{icon}</span>
)}
{children} {children}
{!isLoading && icon && iconPosition === 'right' && ( {!isLoading && icon && iconPosition === 'right' && icon}
<span className={iconSizes[size]}>{icon}</span>
)}
</button> </button>
) )
} }

View File

@@ -8,7 +8,7 @@ import { useToast } from '@/store/toast'
import { import {
ArrowLeft, Loader2, ExternalLink, Image, MessageSquare, ArrowLeft, Loader2, ExternalLink, Image, MessageSquare,
ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle, ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle,
Send, Flag, Gamepad2, Zap, Trophy Send, Flag, Gamepad2, Zap, Trophy, Download, ChevronLeft, ChevronRight, X
} from 'lucide-react' } from 'lucide-react'
export function AssignmentDetailPage() { export function AssignmentDetailPage() {
@@ -23,11 +23,30 @@ export function AssignmentDetailPage() {
const [proofMediaBlobUrl, setProofMediaBlobUrl] = useState<string | null>(null) const [proofMediaBlobUrl, setProofMediaBlobUrl] = useState<string | null>(null)
const [proofMediaType, setProofMediaType] = useState<'image' | 'video' | null>(null) const [proofMediaType, setProofMediaType] = useState<'image' | 'video' | null>(null)
// Multiple proof files
const [proofFiles, setProofFiles] = useState<Array<{ id: number; url: string; type: 'image' | 'video' }>>([])
// Bonus proof media
const [bonusProofMedia, setBonusProofMedia] = useState<Record<number, { url: string; type: 'image' | 'video' }>>({})
// Bonus proof files (multiple)
const [bonusProofFiles, setBonusProofFiles] = useState<Record<number, Array<{ id: number; url: string; type: 'image' | 'video' }>>>({})
// Lightbox state
const [lightboxOpen, setLightboxOpen] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState(0)
const [lightboxItems, setLightboxItems] = useState<Array<{ url: string; type: 'image' | 'video' }>>([])
// Dispute creation // Dispute creation
const [showDisputeForm, setShowDisputeForm] = useState(false) const [showDisputeForm, setShowDisputeForm] = useState(false)
const [disputeReason, setDisputeReason] = useState('') const [disputeReason, setDisputeReason] = useState('')
const [isCreatingDispute, setIsCreatingDispute] = useState(false) const [isCreatingDispute, setIsCreatingDispute] = useState(false)
// Bonus dispute creation
const [activeBonusDisputeId, setActiveBonusDisputeId] = useState<number | null>(null)
const [bonusDisputeReason, setBonusDisputeReason] = useState('')
const [isCreatingBonusDispute, setIsCreatingBonusDispute] = useState(false)
// Comment // Comment
const [commentText, setCommentText] = useState('') const [commentText, setCommentText] = useState('')
const [isAddingComment, setIsAddingComment] = useState(false) const [isAddingComment, setIsAddingComment] = useState(false)
@@ -38,10 +57,24 @@ export function AssignmentDetailPage() {
useEffect(() => { useEffect(() => {
loadAssignment() loadAssignment()
return () => { return () => {
// Cleanup blob URL on unmount // Cleanup blob URLs on unmount
if (proofMediaBlobUrl) { if (proofMediaBlobUrl) {
URL.revokeObjectURL(proofMediaBlobUrl) URL.revokeObjectURL(proofMediaBlobUrl)
} }
proofFiles.forEach(file => {
URL.revokeObjectURL(file.url)
})
Object.values(bonusProofMedia).forEach(media => {
URL.revokeObjectURL(media.url)
})
Object.values(bonusProofFiles).forEach(files => {
files.forEach(file => {
URL.revokeObjectURL(file.url)
})
})
lightboxItems.forEach(item => {
URL.revokeObjectURL(item.url)
})
} }
}, [id]) }, [id])
@@ -53,8 +86,20 @@ export function AssignmentDetailPage() {
const data = await assignmentsApi.getDetail(parseInt(id)) const data = await assignmentsApi.getDetail(parseInt(id))
setAssignment(data) setAssignment(data)
// Load proof media if exists // Load proof files if exists (new multi-file support)
if (data.proof_image_url) { if (data.proof_files && data.proof_files.length > 0) {
const files: Array<{ id: number; url: string; type: 'image' | 'video' }> = []
for (const proofFile of data.proof_files) {
try {
const { url, type } = await assignmentsApi.getProofFileMediaUrl(parseInt(id), proofFile.id)
files.push({ id: proofFile.id, url, type })
} catch {
// Ignore error, file just won't show
}
}
setProofFiles(files)
} else if (data.proof_image_url) {
// Legacy: Load single proof media if exists
try { try {
const { url, type } = await assignmentsApi.getProofMediaUrl(parseInt(id)) const { url, type } = await assignmentsApi.getProofMediaUrl(parseInt(id))
setProofMediaBlobUrl(url) setProofMediaBlobUrl(url)
@@ -63,6 +108,39 @@ export function AssignmentDetailPage() {
// Ignore error, media just won't show // Ignore error, media just won't show
} }
} }
// Load bonus proof files for playthrough
if (data.is_playthrough && data.bonus_challenges) {
const bonusMedia: Record<number, { url: string; type: 'image' | 'video' }> = {}
const bonusFiles: Record<number, Array<{ id: number; url: string; type: 'image' | 'video' }>> = {}
for (const bonus of data.bonus_challenges) {
// New multi-file support
if (bonus.proof_files && bonus.proof_files.length > 0) {
const files: Array<{ id: number; url: string; type: 'image' | 'video' }> = []
for (const proofFile of bonus.proof_files) {
try {
const { url, type } = await assignmentsApi.getBonusProofFileMediaUrl(parseInt(id), bonus.id, proofFile.id)
files.push({ id: proofFile.id, url, type })
} catch {
// Ignore error, file just won't show
}
}
bonusFiles[bonus.id] = files
} else if (bonus.proof_image_url) {
// Legacy: single file
try {
const { url, type } = await assignmentsApi.getBonusProofMediaUrl(parseInt(id), bonus.id)
bonusMedia[bonus.id] = { url, type }
} catch {
// Ignore error, media just won't show
}
}
}
setBonusProofMedia(bonusMedia)
setBonusProofFiles(bonusFiles)
}
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { data?: { detail?: string } } }
setError(error.response?.data?.detail || 'Не удалось загрузить данные') setError(error.response?.data?.detail || 'Не удалось загрузить данные')
@@ -88,6 +166,37 @@ export function AssignmentDetailPage() {
} }
} }
const handleCreateBonusDispute = async (bonusId: number) => {
if (!bonusDisputeReason.trim()) return
setIsCreatingBonusDispute(true)
try {
await assignmentsApi.createBonusDispute(bonusId, bonusDisputeReason)
setBonusDisputeReason('')
setActiveBonusDisputeId(null)
await loadAssignment()
toast.success('Оспаривание бонуса создано')
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось создать оспаривание')
} finally {
setIsCreatingBonusDispute(false)
}
}
const handleBonusVote = async (disputeId: number, vote: boolean) => {
setIsVoting(true)
try {
await assignmentsApi.vote(disputeId, vote)
await loadAssignment()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось проголосовать')
} finally {
setIsVoting(false)
}
}
const handleVote = async (vote: boolean) => { const handleVote = async (vote: boolean) => {
if (!assignment?.dispute) return if (!assignment?.dispute) return
@@ -142,6 +251,24 @@ export function AssignmentDetailPage() {
return `${hours}ч ${minutes}м` return `${hours}ч ${minutes}м`
} }
const openLightbox = (items: Array<{ url: string; type: 'image' | 'video' }>, index: number) => {
setLightboxItems(items)
setLightboxIndex(index)
setLightboxOpen(true)
}
const closeLightbox = () => {
setLightboxOpen(false)
}
const nextLightboxItem = () => {
setLightboxIndex((prev) => (prev + 1) % lightboxItems.length)
}
const prevLightboxItem = () => {
setLightboxIndex((prev) => (prev - 1 + lightboxItems.length) % lightboxItems.length)
}
const getStatusConfig = (status: string) => { const getStatusConfig = (status: string) => {
switch (status) { switch (status) {
case 'completed': case 'completed':
@@ -215,31 +342,54 @@ export function AssignmentDetailPage() {
</div> </div>
</div> </div>
{/* Challenge info */} {/* Challenge/Playthrough info */}
<GlassCard variant="neon"> <GlassCard variant="neon">
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-4"> <div className="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"> <div className={`w-14 h-14 rounded-xl border flex items-center justify-center ${
<Gamepad2 className="w-7 h-7 text-neon-400" /> assignment.is_playthrough
? 'bg-gradient-to-br from-accent-500/20 to-purple-500/20 border-accent-500/20'
: 'bg-gradient-to-br from-neon-500/20 to-accent-500/20 border-neon-500/20'
}`}>
<Gamepad2 className={`w-7 h-7 ${assignment.is_playthrough ? 'text-accent-400' : 'text-neon-400'}`} />
</div> </div>
<div> <div>
<p className="text-gray-400 text-sm">{assignment.challenge.game.title}</p> <p className="text-gray-400 text-sm">
<h2 className="text-xl font-bold text-white">{assignment.challenge.title}</h2> {assignment.is_playthrough ? assignment.game?.title : assignment.challenge?.game.title}
</p>
<h2 className="text-xl font-bold text-white">
{assignment.is_playthrough ? 'Прохождение игры' : assignment.challenge?.title}
</h2>
</div> </div>
</div> </div>
<div className="flex flex-col items-end gap-2">
{assignment.is_playthrough && (
<span className="px-3 py-1 bg-accent-500/20 text-accent-400 rounded-full text-xs font-medium border border-accent-500/30">
Прохождение
</span>
)}
<span className={`px-3 py-1.5 rounded-full text-sm font-medium border flex items-center gap-1.5 ${status.color}`}> <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.icon}
{status.text} {status.text}
</span> </span>
</div> </div>
</div>
<p className="text-gray-300 mb-4">{assignment.challenge.description}</p> <p className="text-gray-300 mb-4">
{assignment.is_playthrough
? assignment.playthrough_info?.description
: assignment.challenge?.description}
</p>
<div className="flex flex-wrap gap-2 mb-4"> <div className="flex flex-wrap gap-2 mb-4">
<span className="px-3 py-1.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"> <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" /> <Trophy className="w-4 h-4" />
+{assignment.challenge.points} очков +{assignment.is_playthrough
? assignment.playthrough_info?.points
: assignment.challenge?.points} очков
</span> </span>
{!assignment.is_playthrough && assignment.challenge && (
<>
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600"> <span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
{assignment.challenge.difficulty} {assignment.challenge.difficulty}
</span> </span>
@@ -249,6 +399,20 @@ export function AssignmentDetailPage() {
~{assignment.challenge.estimated_time} мин ~{assignment.challenge.estimated_time} мин
</span> </span>
)} )}
</>
)}
{/* Download link */}
{(assignment.game?.download_url || assignment.challenge?.game.download_url) && (
<a
href={assignment.is_playthrough ? assignment.game?.download_url : assignment.challenge?.game.download_url}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 bg-neon-500/20 text-neon-400 rounded-lg text-sm font-medium border border-neon-500/30 flex items-center gap-1.5 hover:bg-neon-500/30 transition-colors"
>
<Download className="w-4 h-4" />
Скачать игру
</a>
)}
</div> </div>
<div className="pt-4 border-t border-dark-600 text-sm text-gray-400 space-y-1"> <div className="pt-4 border-t border-dark-600 text-sm text-gray-400 space-y-1">
@@ -271,6 +435,224 @@ export function AssignmentDetailPage() {
</div> </div>
</GlassCard> </GlassCard>
{/* Bonus challenges for playthrough */}
{assignment.is_playthrough && assignment.bonus_challenges && assignment.bonus_challenges.length > 0 && (
<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">
<Trophy className="w-5 h-5 text-accent-400" />
</div>
<div>
<h3 className="font-semibold text-white">Бонусные челленджи</h3>
<p className="text-sm text-gray-400">
Выполнено: {assignment.bonus_challenges.filter((b: { status: string }) => b.status === 'completed').length} из {assignment.bonus_challenges.length}
</p>
</div>
</div>
<div className="space-y-3">
{assignment.bonus_challenges.map((bonus) => (
<div
key={bonus.id}
className={`p-4 rounded-xl border ${
bonus.dispute ? 'bg-yellow-500/10 border-yellow-500/30' :
bonus.status === 'completed'
? 'bg-green-500/10 border-green-500/30'
: 'bg-dark-700/50 border-dark-600'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
{bonus.dispute ? (
<AlertTriangle className="w-4 h-4 text-yellow-400" />
) : bonus.status === 'completed' ? (
<CheckCircle className="w-4 h-4 text-green-400" />
) : null}
<span className="text-white font-medium">{bonus.challenge.title}</span>
{bonus.dispute && (
<span className={`text-xs px-2 py-0.5 rounded ${
bonus.dispute.status === 'open' ? 'bg-yellow-500/20 text-yellow-400' :
bonus.dispute.status === 'valid' ? 'bg-green-500/20 text-green-400' :
'bg-red-500/20 text-red-400'
}`}>
{bonus.dispute.status === 'open' ? 'Оспаривается' :
bonus.dispute.status === 'valid' ? 'Валидно' : 'Невалидно'}
</span>
)}
</div>
<p className="text-gray-400 text-sm">{bonus.challenge.description}</p>
{bonus.status === 'completed' && (bonus.proof_url || bonus.proof_image_url || bonus.proof_comment || bonusProofFiles[bonus.id]) && (
<div className="mt-2 text-xs space-y-2">
{/* Multiple proof files */}
{bonusProofFiles[bonus.id] && bonusProofFiles[bonus.id].length > 0 && (
<div className="flex gap-2 flex-wrap">
{bonusProofFiles[bonus.id].map((file, index) => (
<div
key={file.id}
className="relative rounded-lg overflow-hidden border border-dark-600 cursor-pointer hover:border-neon-500/50 transition-all w-24 h-24"
onClick={() => openLightbox(bonusProofFiles[bonus.id], index)}
>
{file.type === 'video' ? (
<div className="relative w-full h-full">
<video
src={file.url}
className="w-full h-full object-cover bg-dark-900"
preload="metadata"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<div className="w-6 h-6 rounded-full bg-neon-500/80 flex items-center justify-center">
<div className="w-0 h-0 border-l-4 border-l-white border-y-3 border-y-transparent ml-0.5"></div>
</div>
</div>
</div>
) : (
<img
src={file.url}
alt={`Proof ${index + 1}`}
className="w-full h-full object-cover bg-dark-900"
/>
)}
</div>
))}
</div>
)}
{/* Legacy: single proof media */}
{(!bonusProofFiles[bonus.id] || bonusProofFiles[bonus.id].length === 0) && bonusProofMedia[bonus.id] && (
<div className="rounded-lg overflow-hidden border border-dark-600 max-w-xs">
{bonusProofMedia[bonus.id].type === 'video' ? (
<video
src={bonusProofMedia[bonus.id].url}
controls
className="w-full max-h-32 bg-dark-900"
preload="metadata"
/>
) : (
<button
onClick={() => openLightbox([bonusProofMedia[bonus.id]], 0)}
className="w-full"
>
<img
src={bonusProofMedia[bonus.id].url}
alt="Proof"
className="w-full h-auto max-h-32 object-cover hover:opacity-80 transition-opacity"
/>
</button>
)}
</div>
)}
{bonus.proof_url && (
<a
href={bonus.proof_url}
target="_blank"
rel="noopener noreferrer"
className="text-neon-400 hover:underline flex items-center gap-1 break-all"
>
<ExternalLink className="w-3 h-3 shrink-0" />
{bonus.proof_url}
</a>
)}
{bonus.proof_comment && (
<p className="text-gray-400">"{bonus.proof_comment}"</p>
)}
</div>
)}
{/* Bonus dispute form */}
{activeBonusDisputeId === bonus.id && (
<div className="mt-3 p-3 bg-red-500/10 rounded-lg border border-red-500/30">
<textarea
className="input w-full min-h-[80px] resize-none mb-2 text-sm"
placeholder="Причина оспаривания (минимум 10 символов)..."
value={bonusDisputeReason}
onChange={(e) => setBonusDisputeReason(e.target.value)}
/>
<div className="flex gap-2">
<button
className="px-3 py-1.5 text-sm bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 disabled:opacity-50"
onClick={() => handleCreateBonusDispute(bonus.id)}
disabled={bonusDisputeReason.trim().length < 10 || isCreatingBonusDispute}
>
{isCreatingBonusDispute ? 'Создание...' : 'Оспорить'}
</button>
<button
className="px-3 py-1.5 text-sm bg-dark-600 text-gray-300 rounded-lg hover:bg-dark-500"
onClick={() => {
setActiveBonusDisputeId(null)
setBonusDisputeReason('')
}}
>
Отмена
</button>
</div>
</div>
)}
{/* Bonus dispute info */}
{bonus.dispute && (
<div className="mt-3 p-3 bg-yellow-500/10 rounded-lg border border-yellow-500/30">
<p className="text-xs text-gray-400 mb-1">
Оспорил: <span className="text-white">{bonus.dispute.raised_by.nickname}</span>
</p>
<p className="text-sm text-white mb-2">{bonus.dispute.reason}</p>
{bonus.dispute.status === 'open' && (
<div className="flex items-center gap-4 mt-2">
<div className="flex items-center gap-1">
<ThumbsUp className="w-3 h-3 text-green-400" />
<span className="text-green-400 text-sm font-medium">{bonus.dispute.votes_valid}</span>
</div>
<div className="flex items-center gap-1">
<ThumbsDown className="w-3 h-3 text-red-400" />
<span className="text-red-400 text-sm font-medium">{bonus.dispute.votes_invalid}</span>
</div>
<div className="flex gap-1 ml-auto">
<button
className={`p-1.5 rounded ${bonus.dispute.my_vote === true ? 'bg-green-500/30' : 'bg-dark-600 hover:bg-dark-500'}`}
onClick={() => handleBonusVote(bonus.dispute!.id, true)}
disabled={isVoting}
>
<ThumbsUp className="w-3 h-3 text-green-400" />
</button>
<button
className={`p-1.5 rounded ${bonus.dispute.my_vote === false ? 'bg-red-500/30' : 'bg-dark-600 hover:bg-dark-500'}`}
onClick={() => handleBonusVote(bonus.dispute!.id, false)}
disabled={isVoting}
>
<ThumbsDown className="w-3 h-3 text-red-400" />
</button>
</div>
</div>
)}
</div>
)}
</div>
<div className="text-right shrink-0 ml-3 flex flex-col items-end gap-2">
{bonus.status === 'completed' ? (
<span className="text-green-400 font-semibold">+{bonus.points_earned}</span>
) : (
<span className="text-gray-500">+{bonus.challenge.points}</span>
)}
{/* Dispute button for bonus */}
{bonus.can_dispute && !bonus.dispute && activeBonusDisputeId !== bonus.id && (
<button
className="text-xs px-2 py-1 text-red-400 hover:bg-red-500/10 rounded flex items-center gap-1"
onClick={() => setActiveBonusDisputeId(bonus.id)}
>
<Flag className="w-3 h-3" />
Оспорить
</button>
)}
</div>
</div>
</div>
))}
</div>
</GlassCard>
)}
{/* Proof section */} {/* Proof section */}
<GlassCard> <GlassCard>
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
@@ -283,8 +665,47 @@ export function AssignmentDetailPage() {
</div> </div>
</div> </div>
{/* Proof media (image or video) */} {/* Proof files gallery (multiple proofs) */}
{assignment.proof_image_url && ( {proofFiles.length > 0 && (
<div className="mb-4">
<div className="grid grid-cols-2 gap-3">
{proofFiles.map((file, index) => (
<div
key={file.id}
className="relative rounded-xl overflow-hidden border border-dark-600 cursor-pointer hover:border-neon-500/50 transition-all group"
onClick={() => openLightbox(proofFiles, index)}
>
{file.type === 'video' ? (
<div className="relative">
<video
src={file.url}
className="w-full h-48 object-cover bg-dark-900"
preload="metadata"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/50 group-hover:bg-black/30 transition-all">
<div className="w-12 h-12 rounded-full bg-neon-500/80 flex items-center justify-center">
<div className="w-0 h-0 border-l-8 border-l-white border-y-6 border-y-transparent ml-1"></div>
</div>
</div>
</div>
) : (
<img
src={file.url}
alt={`Proof ${index + 1}`}
className="w-full h-48 object-cover bg-dark-900 group-hover:opacity-90 transition-opacity"
/>
)}
<div className="absolute top-2 right-2 px-2 py-1 bg-dark-900/80 rounded text-xs text-gray-300">
{index + 1}/{proofFiles.length}
</div>
</div>
))}
</div>
</div>
)}
{/* Legacy: Single proof media (for backwards compatibility) */}
{proofFiles.length === 0 && assignment.proof_image_url && (
<div className="mb-4 rounded-xl overflow-hidden border border-dark-600"> <div className="mb-4 rounded-xl overflow-hidden border border-dark-600">
{proofMediaBlobUrl ? ( {proofMediaBlobUrl ? (
proofMediaType === 'video' ? ( proofMediaType === 'video' ? (
@@ -295,11 +716,16 @@ export function AssignmentDetailPage() {
preload="metadata" preload="metadata"
/> />
) : ( ) : (
<button
onClick={() => openLightbox([{ url: proofMediaBlobUrl, type: 'image' }], 0)}
className="w-full"
>
<img <img
src={proofMediaBlobUrl} src={proofMediaBlobUrl}
alt="Proof" alt="Proof"
className="w-full max-h-96 object-contain bg-dark-900" className="w-full max-h-96 object-contain bg-dark-900 hover:opacity-90 transition-opacity"
/> />
</button>
) )
) : ( ) : (
<div className="w-full h-48 bg-dark-900 flex items-center justify-center"> <div className="w-full h-48 bg-dark-900 flex items-center justify-center">
@@ -332,7 +758,7 @@ export function AssignmentDetailPage() {
</div> </div>
)} )}
{!assignment.proof_image_url && !assignment.proof_url && ( {proofFiles.length === 0 && !assignment.proof_image_url && !assignment.proof_url && (
<div className="text-center py-8"> <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"> <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" /> <Image className="w-6 h-6 text-gray-600" />
@@ -548,6 +974,69 @@ export function AssignmentDetailPage() {
</div> </div>
</GlassCard> </GlassCard>
)} )}
{/* Lightbox modal */}
{lightboxOpen && lightboxItems.length > 0 && (
<div
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
onClick={closeLightbox}
>
<button
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-dark-700/80 hover:bg-dark-600 flex items-center justify-center text-gray-400 hover:text-white transition-all z-10"
onClick={closeLightbox}
>
<X className="w-6 h-6" />
</button>
{lightboxItems.length > 1 && (
<>
<button
className="absolute left-4 w-12 h-12 rounded-full bg-dark-700/80 hover:bg-dark-600 flex items-center justify-center text-gray-400 hover:text-white transition-all z-10"
onClick={(e) => {
e.stopPropagation()
prevLightboxItem()
}}
>
<ChevronLeft className="w-8 h-8" />
</button>
<button
className="absolute right-4 w-12 h-12 rounded-full bg-dark-700/80 hover:bg-dark-600 flex items-center justify-center text-gray-400 hover:text-white transition-all z-10"
onClick={(e) => {
e.stopPropagation()
nextLightboxItem()
}}
>
<ChevronRight className="w-8 h-8" />
</button>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 px-4 py-2 bg-dark-700/80 rounded-full text-white text-sm z-10">
{lightboxIndex + 1} / {lightboxItems.length}
</div>
</>
)}
<div
className="max-w-7xl max-h-[90vh] w-full h-full flex items-center justify-center p-4"
onClick={(e) => e.stopPropagation()}
>
{lightboxItems[lightboxIndex].type === 'video' ? (
<video
src={lightboxItems[lightboxIndex].url}
controls
autoPlay
className="max-w-full max-h-full"
/>
) : (
<img
src={lightboxItems[lightboxIndex].url}
alt="Proof"
className="max-w-full max-h-full object-contain"
/>
)}
</div>
</div>
)}
</div> </div>
) )
} }

View File

@@ -1,12 +1,13 @@
import { useState } from 'react' import { useState, useRef } from 'react'
import { useNavigate, Link } from 'react-router-dom' import { useNavigate, Link } from 'react-router-dom'
import { useForm } from 'react-hook-form' 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 { NeonButton, Input, GlassCard } from '@/components/ui' import { NeonButton, Input, GlassCard } from '@/components/ui'
import { Globe, Lock, Users, UserCog, ArrowLeft, Gamepad2, AlertCircle, Sparkles, Calendar, Clock } from 'lucide-react' import { Globe, Lock, Users, UserCog, ArrowLeft, Gamepad2, AlertCircle, Sparkles, Calendar, Clock, Camera, Trash2 } from 'lucide-react'
import type { GameProposalMode } from '@/types' import type { GameProposalMode } from '@/types'
import { useToast } from '@/store/toast'
const createSchema = z.object({ const createSchema = z.object({
title: z.string().min(1, 'Название обязательно').max(100), title: z.string().min(1, 'Название обязательно').max(100),
@@ -21,8 +22,12 @@ type CreateForm = z.infer<typeof createSchema>
export function CreateMarathonPage() { export function CreateMarathonPage() {
const navigate = useNavigate() const navigate = useNavigate()
const toast = useToast()
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [coverFile, setCoverFile] = useState<File | null>(null)
const [coverPreview, setCoverPreview] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const { const {
register, register,
@@ -42,6 +47,38 @@ export function CreateMarathonPage() {
const isPublic = watch('is_public') const isPublic = watch('is_public')
const gameProposalMode = watch('game_proposal_mode') const gameProposalMode = watch('game_proposal_mode')
const handleCoverClick = () => {
fileInputRef.current?.click()
}
const handleCoverChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
toast.error('Файл должен быть изображением')
return
}
if (file.size > 5 * 1024 * 1024) {
toast.error('Максимальный размер файла 5 МБ')
return
}
setCoverFile(file)
setCoverPreview(URL.createObjectURL(file))
}
const handleRemoveCover = () => {
setCoverFile(null)
if (coverPreview) {
URL.revokeObjectURL(coverPreview)
}
setCoverPreview(null)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
const onSubmit = async (data: CreateForm) => { const onSubmit = async (data: CreateForm) => {
setIsLoading(true) setIsLoading(true)
setError(null) setError(null)
@@ -54,6 +91,16 @@ export function CreateMarathonPage() {
is_public: data.is_public, is_public: data.is_public,
game_proposal_mode: data.game_proposal_mode as GameProposalMode, game_proposal_mode: data.game_proposal_mode as GameProposalMode,
}) })
// Upload cover if selected
if (coverFile) {
try {
await marathonsApi.uploadCover(marathon.id, coverFile)
} catch {
toast.warning('Марафон создан, но не удалось загрузить обложку')
}
}
navigate(`/marathons/${marathon.id}/lobby`) navigate(`/marathons/${marathon.id}/lobby`)
} catch (err: unknown) { } catch (err: unknown) {
const apiError = err as { response?: { data?: { detail?: string } } } const apiError = err as { response?: { data?: { detail?: string } } }
@@ -94,6 +141,57 @@ export function CreateMarathonPage() {
</div> </div>
)} )}
{/* Cover Image */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-3">
Обложка (необязательно)
</label>
<div className="relative group">
<button
type="button"
onClick={handleCoverClick}
disabled={isLoading}
className="relative w-full h-40 rounded-xl overflow-hidden bg-dark-700 border-2 border-dashed border-dark-500 hover:border-neon-500/50 transition-all"
>
{coverPreview ? (
<img
src={coverPreview}
alt="Обложка марафона"
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-gray-500">
<Camera className="w-8 h-8 mb-2" />
<span className="text-sm">Нажмите для загрузки</span>
<span className="text-xs text-gray-600 mt-1">JPG, PNG до 5 МБ</span>
</div>
)}
{coverPreview && (
<div className="absolute inset-0 bg-dark-900/70 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Camera className="w-6 h-6 text-neon-500" />
<span className="ml-2 text-white text-sm">Изменить</span>
</div>
)}
</button>
{coverPreview && (
<button
type="button"
onClick={handleRemoveCover}
className="absolute top-2 right-2 p-2 rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleCoverChange}
className="hidden"
/>
</div>
{/* Basic info */} {/* Basic info */}
<div className="space-y-4"> <div className="space-y-4">
<Input <Input

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ import { z } from 'zod'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { marathonsApi } from '@/api' import { marathonsApi } from '@/api'
import { NeonButton, Input, GlassCard } from '@/components/ui' import { NeonButton, Input, GlassCard } from '@/components/ui'
import { Gamepad2, LogIn, AlertCircle, Trophy, Users, Zap, Target } from 'lucide-react' import { Gamepad2, LogIn, AlertCircle, Trophy, Users, Zap, Target, Shield, ArrowLeft } from 'lucide-react'
const loginSchema = z.object({ const loginSchema = z.object({
login: z.string().min(3, 'Логин должен быть не менее 3 символов'), login: z.string().min(3, 'Логин должен быть не менее 3 символов'),
@@ -17,8 +17,9 @@ type LoginForm = z.infer<typeof loginSchema>
export function LoginPage() { export function LoginPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { login, isLoading, error, clearError, consumePendingInviteCode } = useAuthStore() const { login, verify2FA, cancel2FA, pending2FA, isLoading, error, clearError, consumePendingInviteCode } = useAuthStore()
const [submitError, setSubmitError] = useState<string | null>(null) const [submitError, setSubmitError] = useState<string | null>(null)
const [twoFACode, setTwoFACode] = useState('')
const { const {
register, register,
@@ -32,7 +33,12 @@ export function LoginPage() {
setSubmitError(null) setSubmitError(null)
clearError() clearError()
try { try {
await login(data) const result = await login(data)
// If 2FA required, don't navigate
if (result.requires2FA) {
return
}
// Check for pending invite code // Check for pending invite code
const pendingCode = consumePendingInviteCode() const pendingCode = consumePendingInviteCode()
@@ -48,10 +54,29 @@ export function LoginPage() {
navigate('/marathons') navigate('/marathons')
} catch { } catch {
setSubmitError(error || 'Ошибка входа') // Error is already set in store by login function
// Ban case is handled separately via banInfo state
} }
} }
const handle2FASubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitError(null)
clearError()
try {
await verify2FA(twoFACode)
navigate('/marathons')
} catch {
setSubmitError(error || 'Неверный код')
}
}
const handleCancel2FA = () => {
cancel2FA()
setTwoFACode('')
setSubmitError(null)
}
const features = [ const features = [
{ icon: <Trophy className="w-5 h-5" />, text: 'Соревнуйтесь с друзьями' }, { icon: <Trophy className="w-5 h-5" />, text: 'Соревнуйтесь с друзьями' },
{ icon: <Target className="w-5 h-5" />, text: 'Выполняйте челленджи' }, { icon: <Target className="w-5 h-5" />, text: 'Выполняйте челленджи' },
@@ -113,6 +138,63 @@ export function LoginPage() {
{/* Form Block (right) */} {/* Form Block (right) */}
<GlassCard className="p-8"> <GlassCard className="p-8">
{pending2FA ? (
// 2FA Form
<>
{/* Header */}
<div className="text-center mb-8">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-neon-500/10 border border-neon-500/30 flex items-center justify-center">
<Shield className="w-8 h-8 text-neon-500" />
</div>
<h2 className="text-2xl font-bold text-white mb-2">Двухфакторная аутентификация</h2>
<p className="text-gray-400">Введите код из Telegram</p>
</div>
{/* 2FA Form */}
<form onSubmit={handle2FASubmit} className="space-y-5">
{(submitError || error) && (
<div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span>{submitError || error}</span>
</div>
)}
<Input
label="Код подтверждения"
placeholder="000000"
value={twoFACode}
onChange={(e) => setTwoFACode(e.target.value.replace(/\D/g, '').slice(0, 6))}
maxLength={6}
className="text-center text-2xl tracking-widest font-mono"
autoFocus
/>
<NeonButton
type="submit"
className="w-full"
size="lg"
isLoading={isLoading}
disabled={twoFACode.length !== 6}
icon={<Shield className="w-5 h-5" />}
>
Подтвердить
</NeonButton>
</form>
{/* Back button */}
<div className="mt-6 pt-6 border-t border-dark-600 text-center">
<button
onClick={handleCancel2FA}
className="text-gray-400 hover:text-white transition-colors text-sm flex items-center justify-center gap-2 mx-auto"
>
<ArrowLeft className="w-4 h-4" />
Вернуться к входу
</button>
</div>
</>
) : (
// Regular Login Form
<>
{/* Header */} {/* Header */}
<div className="text-center mb-8"> <div className="text-center mb-8">
<h2 className="text-2xl font-bold text-white mb-2">Добро пожаловать!</h2> <h2 className="text-2xl font-bold text-white mb-2">Добро пожаловать!</h2>
@@ -168,6 +250,8 @@ export function LoginPage() {
</Link> </Link>
</p> </p>
</div> </div>
</>
)}
</GlassCard> </GlassCard>
</div> </div>

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } 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, MarathonDispute } from '@/types'
import { NeonButton, GlassCard, StatsCard } from '@/components/ui' import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
@@ -9,13 +9,16 @@ import { useConfirm } from '@/store/confirm'
import { EventBanner } from '@/components/EventBanner' import { EventBanner } from '@/components/EventBanner'
import { EventControl } from '@/components/EventControl' import { EventControl } from '@/components/EventControl'
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed' import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
import { MarathonSettingsModal } from '@/components/MarathonSettingsModal'
import { import {
Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2, Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2,
Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag, Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag,
Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2, Sparkles Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2, Sparkles,
AlertTriangle, Clock, CheckCircle, XCircle, ThumbsUp, ThumbsDown, ExternalLink, User
} from 'lucide-react' } from 'lucide-react'
import { format } from 'date-fns' import { format } from 'date-fns'
import { ru } from 'date-fns/locale' import { ru } from 'date-fns/locale'
import { TelegramBotBanner } from '@/components/TelegramBotBanner'
export function MarathonPage() { export function MarathonPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
@@ -34,12 +37,26 @@ export function MarathonPage() {
const [showEventControl, setShowEventControl] = useState(false) const [showEventControl, setShowEventControl] = useState(false)
const [showChallenges, setShowChallenges] = useState(false) const [showChallenges, setShowChallenges] = useState(false)
const [expandedGameId, setExpandedGameId] = useState<number | null>(null) const [expandedGameId, setExpandedGameId] = useState<number | null>(null)
const [showSettings, setShowSettings] = useState(false)
const activityFeedRef = useRef<ActivityFeedRef>(null) const activityFeedRef = useRef<ActivityFeedRef>(null)
// Disputes for organizers
const [showDisputes, setShowDisputes] = useState(false)
const [disputes, setDisputes] = useState<MarathonDispute[]>([])
const [loadingDisputes, setLoadingDisputes] = useState(false)
const [disputeFilter, setDisputeFilter] = useState<'open' | 'all'>('open')
const [resolvingDisputeId, setResolvingDisputeId] = useState<number | null>(null)
useEffect(() => { useEffect(() => {
loadMarathon() loadMarathon()
}, [id]) }, [id])
useEffect(() => {
if (showDisputes) {
loadDisputes()
}
}, [showDisputes, disputeFilter])
const loadMarathon = async () => { const loadMarathon = async () => {
if (!id) return if (!id) return
try { try {
@@ -77,6 +94,57 @@ export function MarathonPage() {
} }
} }
const loadDisputes = async () => {
if (!id) return
setLoadingDisputes(true)
try {
const data = await marathonsApi.listDisputes(parseInt(id), disputeFilter)
setDisputes(data)
} catch (error) {
console.error('Failed to load disputes:', error)
toast.error('Не удалось загрузить оспаривания')
} finally {
setLoadingDisputes(false)
}
}
const handleResolveDispute = async (disputeId: number, isValid: boolean) => {
if (!id) return
setResolvingDisputeId(disputeId)
try {
await marathonsApi.resolveDispute(parseInt(id), disputeId, isValid)
toast.success(isValid ? 'Пруф подтверждён' : 'Пруф отклонён')
await loadDisputes()
} catch (error) {
console.error('Failed to resolve dispute:', error)
toast.error('Не удалось разрешить диспут')
} finally {
setResolvingDisputeId(null)
}
}
const formatDisputeDate = (dateString: string) => {
return new Date(dateString).toLocaleString('ru-RU', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})
}
const getDisputeTimeRemaining = (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 getInviteLink = () => { const getInviteLink = () => {
if (!marathon) return '' if (!marathon) return ''
return `${window.location.origin}/invite/${marathon.invite_code}` return `${window.location.origin}/invite/${marathon.invite_code}`
@@ -189,8 +257,22 @@ export function MarathonPage() {
{/* Hero Banner */} {/* Hero Banner */}
<div className="relative rounded-2xl overflow-hidden mb-8"> <div className="relative rounded-2xl overflow-hidden mb-8">
{/* Background */} {/* Background */}
{marathon.cover_url ? (
<>
<img
src={marathon.cover_url}
alt=""
className="absolute inset-0 w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-r from-dark-900/95 via-dark-900/80 to-dark-900/60" />
<div className="absolute inset-0 bg-gradient-to-t from-dark-900/90 to-transparent" />
</>
) : (
<>
<div className="absolute inset-0 bg-gradient-to-br from-neon-500/10 via-dark-800 to-accent-500/10" /> <div className="absolute inset-0 bg-gradient-to-br from-neon-500/10 via-dark-800 to-accent-500/10" />
<div className="absolute inset-0 bg-[linear-gradient(rgba(34,211,238,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(34,211,238,0.02)_1px,transparent_1px)] bg-[size:50px_50px]" /> <div className="absolute inset-0 bg-[linear-gradient(rgba(34,211,238,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(34,211,238,0.02)_1px,transparent_1px)] bg-[size:50px_50px]" />
</>
)}
<div className="relative p-8"> <div className="relative p-8">
<div className="flex flex-col md:flex-row justify-between items-start gap-6"> <div className="flex flex-col md:flex-row justify-between items-start gap-6">
@@ -226,8 +308,8 @@ export function MarathonPage() {
{marathon.status === 'preparing' && isOrganizer && ( {marathon.status === 'preparing' && isOrganizer && (
<Link to={`/marathons/${id}/lobby`}> <Link to={`/marathons/${id}/lobby`}>
<NeonButton variant="secondary" icon={<Settings className="w-4 h-4" />}> <NeonButton variant="secondary" icon={<Gamepad2 className="w-4 h-4" />}>
Настройка Игры
</NeonButton> </NeonButton>
</Link> </Link>
)} )}
@@ -265,6 +347,15 @@ export function MarathonPage() {
</button> </button>
)} )}
{marathon.status === 'preparing' && isOrganizer && (
<NeonButton
variant="ghost"
onClick={() => setShowSettings(true)}
className="!text-gray-400 hover:!bg-dark-600"
icon={<Settings className="w-4 h-4" />}
/>
)}
{canDelete && ( {canDelete && (
<NeonButton <NeonButton
variant="ghost" variant="ghost"
@@ -316,6 +407,9 @@ export function MarathonPage() {
/> />
</div> </div>
{/* Telegram Bot Banner */}
<TelegramBotBanner />
{/* Active event banner */} {/* Active event banner */}
{marathon.status === 'active' && activeEvent?.event && ( {marathon.status === 'active' && activeEvent?.event && (
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} /> <EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
@@ -356,6 +450,196 @@ export function MarathonPage() {
</GlassCard> </GlassCard>
)} )}
{/* Disputes management for organizers */}
{marathon.status === 'active' && isOrganizer && (
<GlassCard>
<button
onClick={() => setShowDisputes(!showDisputes)}
className="w-full flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-orange-500/20 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-orange-400" />
</div>
<div className="text-left">
<h3 className="font-semibold text-white">Оспаривания</h3>
<p className="text-sm text-gray-400">Проверьте спорные выполнения</p>
</div>
</div>
<div className="flex items-center gap-3">
{disputes.filter(d => d.status === 'open').length > 0 && (
<span className="px-2 py-1 bg-orange-500/20 text-orange-400 rounded text-xs font-medium">
{disputes.filter(d => d.status === 'open').length} открыто
</span>
)}
{showDisputes ? (
<ChevronUp className="w-5 h-5 text-gray-400" />
) : (
<ChevronDown className="w-5 h-5 text-gray-400" />
)}
</div>
</button>
{showDisputes && (
<div className="mt-6 pt-6 border-t border-dark-600">
{/* Filters */}
<div className="flex gap-2 mb-4">
<button
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${
disputeFilter === 'open'
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
}`}
onClick={() => setDisputeFilter('open')}
>
Открытые
</button>
<button
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${
disputeFilter === 'all'
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30'
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
}`}
onClick={() => setDisputeFilter('all')}
>
Все
</button>
</div>
{/* Loading */}
{loadingDisputes ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-accent-500" />
</div>
) : disputes.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-green-500/10 border border-green-500/30 flex items-center justify-center">
<CheckCircle className="w-6 h-6 text-green-400" />
</div>
<p className="text-gray-400 text-sm">
{disputeFilter === 'open' ? 'Нет открытых оспариваний' : 'Нет оспариваний'}
</p>
</div>
) : (
<div className="space-y-3">
{disputes.map((dispute) => (
<div
key={dispute.id}
className={`p-4 bg-dark-700/50 rounded-xl border ${
dispute.status === 'open' ? 'border-orange-500/30' : 'border-dark-600'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
{/* Challenge title */}
<h4 className="text-white font-medium truncate mb-1">
{dispute.challenge_title}
</h4>
{/* Participants */}
<div className="flex flex-wrap gap-3 text-xs text-gray-400 mb-2">
<span className="flex items-center gap-1">
<User className="w-3 h-3" />
Автор: <span className="text-white">{dispute.participant_nickname}</span>
</span>
<span className="flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Оспорил: <span className="text-white">{dispute.raised_by_nickname}</span>
</span>
</div>
{/* Reason */}
<p className="text-sm text-gray-300 mb-2 line-clamp-2">
{dispute.reason}
</p>
{/* Votes & Time */}
<div className="flex items-center gap-3 text-xs">
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-0.5 text-green-400">
<ThumbsUp className="w-3 h-3" />
<span>{dispute.votes_valid}</span>
</div>
<span className="text-gray-600">/</span>
<div className="flex items-center gap-0.5 text-red-400">
<ThumbsDown className="w-3 h-3" />
<span>{dispute.votes_invalid}</span>
</div>
</div>
<span className="text-gray-500">{formatDisputeDate(dispute.created_at)}</span>
{dispute.status === 'open' && (
<span className="text-orange-400 flex items-center gap-1">
<Clock className="w-3 h-3" />
{getDisputeTimeRemaining(dispute.expires_at)}
</span>
)}
</div>
</div>
{/* Right side - Status & Actions */}
<div className="flex flex-col items-end gap-2 shrink-0">
{dispute.status === 'open' ? (
<span className="px-2 py-1 bg-yellow-500/20 text-yellow-400 rounded text-xs font-medium flex items-center gap-1">
<Clock className="w-3 h-3" />
Открыт
</span>
) : dispute.status === 'valid' ? (
<span className="px-2 py-1 bg-green-500/20 text-green-400 rounded text-xs font-medium flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Валидно
</span>
) : (
<span className="px-2 py-1 bg-red-500/20 text-red-400 rounded text-xs font-medium flex items-center gap-1">
<XCircle className="w-3 h-3" />
Невалидно
</span>
)}
{/* Link to assignment */}
{dispute.assignment_id && (
<Link
to={`/assignments/${dispute.assignment_id}`}
className="text-xs text-accent-400 hover:text-accent-300 flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
Открыть
</Link>
)}
{/* Resolution buttons */}
{dispute.status === 'open' && (
<div className="flex gap-1.5 mt-1">
<NeonButton
size="sm"
variant="outline"
className="border-green-500/50 text-green-400 hover:bg-green-500/10 !px-2 !py-1 text-xs"
onClick={() => handleResolveDispute(dispute.id, true)}
isLoading={resolvingDisputeId === dispute.id}
disabled={resolvingDisputeId !== null}
icon={<CheckCircle className="w-3 h-3" />}
>
Валидно
</NeonButton>
<NeonButton
size="sm"
variant="outline"
className="border-red-500/50 text-red-400 hover:bg-red-500/10 !px-2 !py-1 text-xs"
onClick={() => handleResolveDispute(dispute.id, false)}
isLoading={resolvingDisputeId === dispute.id}
disabled={resolvingDisputeId !== null}
icon={<XCircle className="w-3 h-3" />}
>
Невалидно
</NeonButton>
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</GlassCard>
)}
{/* Invite link */} {/* Invite link */}
{marathon.status !== 'finished' && ( {marathon.status !== 'finished' && (
<GlassCard> <GlassCard>
@@ -529,6 +813,14 @@ export function MarathonPage() {
</div> </div>
)} )}
</div> </div>
{/* Settings Modal */}
<MarathonSettingsModal
marathon={marathon}
isOpen={showSettings}
onClose={() => setShowSettings(false)}
onUpdate={setMarathon}
/>
</div> </div>
) )
} }

View File

@@ -4,6 +4,8 @@ import { marathonsApi } from '@/api'
import type { MarathonListItem } from '@/types' import type { MarathonListItem } from '@/types'
import { NeonButton, GlassCard, StatsCard } from '@/components/ui' import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { Plus, Users, Calendar, Loader2, Trophy, Gamepad2, ChevronRight, Hash, Sparkles } from 'lucide-react' import { Plus, Users, Calendar, Loader2, Trophy, Gamepad2, ChevronRight, Hash, Sparkles } from 'lucide-react'
import { TelegramBotBanner } from '@/components/TelegramBotBanner'
import { AnnouncementBanner } from '@/components/AnnouncementBanner'
import { format } from 'date-fns' import { format } from 'date-fns'
import { ru } from 'date-fns/locale' import { ru } from 'date-fns/locale'
@@ -145,6 +147,16 @@ export function MarathonsPage() {
</div> </div>
)} )}
{/* Announcement Banner */}
<div className="mb-4">
<AnnouncementBanner />
</div>
{/* Telegram Bot Banner */}
<div className="mb-8">
<TelegramBotBanner />
</div>
{/* Join marathon */} {/* Join marathon */}
{showJoinSection && ( {showJoinSection && (
<GlassCard className="mb-8 animate-slide-in-down" variant="neon"> <GlassCard className="mb-8 animate-slide-in-down" variant="neon">
@@ -221,10 +233,20 @@ export function MarathonsPage() {
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Icon */} {/* Cover or Icon */}
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center border border-neon-500/20 group-hover:border-neon-500/40 transition-colors"> {marathon.cover_url ? (
<div className="w-14 h-14 rounded-xl overflow-hidden border border-dark-500 group-hover:border-neon-500/40 transition-colors flex-shrink-0">
<img
src={marathon.cover_url}
alt={marathon.title}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center border border-neon-500/20 group-hover:border-neon-500/40 transition-colors flex-shrink-0">
<Gamepad2 className="w-7 h-7 text-neon-400" /> <Gamepad2 className="w-7 h-7 text-neon-400" />
</div> </div>
)}
{/* Info */} {/* Info */}
<div> <div>

View File

@@ -5,7 +5,7 @@ import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequ
import { NeonButton, GlassCard, StatsCard } from '@/components/ui' import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { SpinWheel } from '@/components/SpinWheel' import { SpinWheel } from '@/components/SpinWheel'
import { EventBanner } from '@/components/EventBanner' import { EventBanner } from '@/components/EventBanner'
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target } from 'lucide-react' import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target, Download } from 'lucide-react'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm' import { useConfirm } from '@/store/confirm'
@@ -25,7 +25,7 @@ export function PlayPage() {
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null) const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [proofFile, setProofFile] = useState<File | null>(null) const [proofFiles, setProofFiles] = useState<File[]>([])
const [proofUrl, setProofUrl] = useState('') const [proofUrl, setProofUrl] = useState('')
const [comment, setComment] = useState('') const [comment, setComment] = useState('')
const [isCompleting, setIsCompleting] = useState(false) const [isCompleting, setIsCompleting] = useState(false)
@@ -55,6 +55,15 @@ export function PlayPage() {
const [returnedAssignments, setReturnedAssignments] = useState<ReturnedAssignment[]>([]) const [returnedAssignments, setReturnedAssignments] = useState<ReturnedAssignment[]>([])
// Bonus challenge completion
const [expandedBonusId, setExpandedBonusId] = useState<number | null>(null)
const [bonusProofFiles, setBonusProofFiles] = useState<File[]>([])
const [bonusProofUrl, setBonusProofUrl] = useState('')
const [bonusComment, setBonusComment] = useState('')
const [isCompletingBonus, setIsCompletingBonus] = useState(false)
const bonusFileInputRef = useRef<HTMLInputElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const eventFileInputRef = useRef<HTMLInputElement>(null) const eventFileInputRef = useRef<HTMLInputElement>(null)
@@ -168,17 +177,17 @@ export function PlayPage() {
const loadData = async () => { const loadData = async () => {
if (!id) return if (!id) return
try { try {
const [marathonData, assignment, gamesData, eventData, eventAssignmentData, returnedData] = await Promise.all([ const [marathonData, assignment, availableGamesData, eventData, eventAssignmentData, returnedData] = await Promise.all([
marathonsApi.get(parseInt(id)), marathonsApi.get(parseInt(id)),
wheelApi.getCurrentAssignment(parseInt(id)), wheelApi.getCurrentAssignment(parseInt(id)),
gamesApi.list(parseInt(id), 'approved'), gamesApi.getAvailableGames(parseInt(id)),
eventsApi.getActive(parseInt(id)), eventsApi.getActive(parseInt(id)),
eventsApi.getEventAssignment(parseInt(id)), eventsApi.getEventAssignment(parseInt(id)),
assignmentsApi.getReturnedAssignments(parseInt(id)), assignmentsApi.getReturnedAssignments(parseInt(id)),
]) ])
setMarathon(marathonData) setMarathon(marathonData)
setCurrentAssignment(assignment) setCurrentAssignment(assignment)
setGames(gamesData) setGames(availableGamesData)
setActiveEvent(eventData) setActiveEvent(eventData)
setEventAssignment(eventAssignmentData) setEventAssignment(eventAssignmentData)
setReturnedAssignments(returnedData) setReturnedAssignments(returnedData)
@@ -219,20 +228,30 @@ export function PlayPage() {
const handleComplete = async () => { const handleComplete = async () => {
if (!currentAssignment) return if (!currentAssignment) return
if (!proofFile && !proofUrl) {
// For playthrough: allow file, URL, or comment
// For challenges: require file or URL
if (currentAssignment.is_playthrough) {
if (proofFiles.length === 0 && !proofUrl && !comment) {
toast.warning('Пожалуйста, предоставьте доказательство (файл, ссылку или комментарий)')
return
}
} else {
if (proofFiles.length === 0 && !proofUrl) {
toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)') toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
return return
} }
}
setIsCompleting(true) setIsCompleting(true)
try { try {
const result = await wheelApi.complete(currentAssignment.id, { const result = await wheelApi.complete(currentAssignment.id, {
proof_file: proofFile || undefined, proof_files: proofFiles.length > 0 ? proofFiles : undefined,
proof_url: proofUrl || undefined, proof_url: proofUrl || undefined,
comment: comment || undefined, comment: comment || undefined,
}) })
toast.success(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`) toast.success(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`)
setProofFile(null) setProofFiles([])
setProofUrl('') setProofUrl('')
setComment('') setComment('')
await loadData() await loadData()
@@ -270,6 +289,39 @@ export function PlayPage() {
} }
} }
const handleBonusComplete = async (bonusId: number) => {
if (!currentAssignment) return
if (bonusProofFiles.length === 0 && !bonusProofUrl && !bonusComment) {
toast.warning('Прикрепите файл, ссылку или комментарий')
return
}
setIsCompletingBonus(true)
try {
const result = await assignmentsApi.completeBonusAssignment(
currentAssignment.id,
bonusId,
{
proof_files: bonusProofFiles.length > 0 ? bonusProofFiles : undefined,
proof_url: bonusProofUrl || undefined,
comment: bonusComment || undefined,
}
)
toast.success(`Бонус отмечен! +${result.points_earned} очков начислится при завершении игры`)
setBonusProofFiles([])
setBonusProofUrl('')
setBonusComment('')
setExpandedBonusId(null)
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
await loadData()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось выполнить бонус')
} finally {
setIsCompletingBonus(false)
}
}
const handleEventComplete = async () => { const handleEventComplete = async () => {
if (!eventAssignment?.assignment) return if (!eventAssignment?.assignment) return
if (!eventProofFile && !eventProofUrl) { if (!eventProofFile && !eventProofUrl) {
@@ -529,12 +581,23 @@ export function PlayPage() {
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
{ra.is_playthrough ? (
<>
<p className="text-white font-medium">Прохождение: {ra.game_title}</p>
<p className="text-gray-400 text-sm">Прохождение игры</p>
</>
) : ra.challenge ? (
<>
<p className="text-white font-medium">{ra.challenge.title}</p> <p className="text-white font-medium">{ra.challenge.title}</p>
<p className="text-gray-400 text-sm">{ra.challenge.game.title}</p> <p className="text-gray-400 text-sm">{ra.challenge.game.title}</p>
</>
) : null}
</div> </div>
{!ra.is_playthrough && ra.challenge && (
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded-lg border border-green-500/30"> <span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded-lg border border-green-500/30">
+{ra.challenge.points} +{ra.challenge.points}
</span> </span>
)}
</div> </div>
<p className="text-orange-300 text-xs mt-2"> <p className="text-orange-300 text-xs mt-2">
Причина: {ra.dispute_reason} Причина: {ra.dispute_reason}
@@ -640,28 +703,28 @@ export function PlayPage() {
<div className="mb-4"> <div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Игра</p> <p className="text-gray-400 text-sm mb-1">Игра</p>
<p className="text-xl font-bold text-white"> <p className="text-xl font-bold text-white">
{eventAssignment.assignment.challenge.game.title} {eventAssignment.assignment.challenge?.game.title}
</p> </p>
</div> </div>
<div className="mb-4"> <div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Задание</p> <p className="text-gray-400 text-sm mb-1">Задание</p>
<p className="text-xl font-bold text-neon-400 mb-2"> <p className="text-xl font-bold text-neon-400 mb-2">
{eventAssignment.assignment.challenge.title} {eventAssignment.assignment.challenge?.title}
</p> </p>
<p className="text-gray-300"> <p className="text-gray-300">
{eventAssignment.assignment.challenge.description} {eventAssignment.assignment.challenge?.description}
</p> </p>
</div> </div>
<div className="flex items-center gap-3 mb-6 flex-wrap"> <div className="flex items-center gap-3 mb-6 flex-wrap">
<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"> <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">
+{eventAssignment.assignment.challenge.points} очков +{eventAssignment.assignment.challenge?.points} очков
</span> </span>
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600"> <span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
{eventAssignment.assignment.challenge.difficulty} {eventAssignment.assignment.challenge?.difficulty}
</span> </span>
{eventAssignment.assignment.challenge.estimated_time && ( {eventAssignment.assignment.challenge?.estimated_time && (
<span className="text-gray-400 text-sm flex items-center gap-1"> <span className="text-gray-400 text-sm flex items-center gap-1">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
~{eventAssignment.assignment.challenge.estimated_time} мин ~{eventAssignment.assignment.challenge.estimated_time} мин
@@ -669,7 +732,7 @@ export function PlayPage() {
)} )}
</div> </div>
{eventAssignment.assignment.challenge.proof_hint && ( {eventAssignment.assignment.challenge?.proof_hint && (
<div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600"> <div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
<strong className="text-white">Нужно доказательство:</strong> {eventAssignment.assignment.challenge.proof_hint} <strong className="text-white">Нужно доказательство:</strong> {eventAssignment.assignment.challenge.proof_hint}
@@ -680,7 +743,7 @@ export function PlayPage() {
<div className="space-y-4 mb-6"> <div className="space-y-4 mb-6">
<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-2">
Загрузить доказательство ({eventAssignment.assignment.challenge.proof_type}) Загрузить доказательство ({eventAssignment.assignment.challenge?.proof_type})
</label> </label>
<input <input
@@ -891,36 +954,258 @@ export function PlayPage() {
<> <>
<GlassCard variant="neon"> <GlassCard variant="neon">
<div className="text-center mb-6"> <div className="text-center mb-6">
<span className="px-4 py-1.5 bg-neon-500/20 text-neon-400 rounded-full text-sm font-medium border border-neon-500/30"> <span className={`px-4 py-1.5 rounded-full text-sm font-medium border ${
Активное задание currentAssignment.is_playthrough
? 'bg-accent-500/20 text-accent-400 border-accent-500/30'
: 'bg-neon-500/20 text-neon-400 border-neon-500/30'
}`}>
{currentAssignment.is_playthrough ? 'Прохождение игры' : 'Активное задание'}
</span> </span>
</div> </div>
<div className="mb-4"> <div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Игра</p> <p className="text-gray-400 text-sm mb-1">Игра</p>
<div className="flex items-center justify-between gap-3 flex-wrap">
<p className="text-xl font-bold text-white"> <p className="text-xl font-bold text-white">
{currentAssignment.challenge.game.title} {currentAssignment.is_playthrough
? currentAssignment.game?.title
: currentAssignment.challenge?.game.title}
</p> </p>
{(currentAssignment.is_playthrough
? currentAssignment.game?.download_url
: currentAssignment.challenge?.game.download_url) && (
<a
href={currentAssignment.is_playthrough
? currentAssignment.game?.download_url
: currentAssignment.challenge?.game.download_url}
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1.5 bg-neon-500/20 text-neon-400 rounded-lg text-sm font-medium border border-neon-500/30 flex items-center gap-1.5 hover:bg-neon-500/30 transition-colors"
>
<Download className="w-4 h-4" />
Скачать игру
</a>
)}
</div>
</div> </div>
{currentAssignment.is_playthrough ? (
// Playthrough task
<>
<div className="mb-4"> <div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Задание</p> <p className="text-gray-400 text-sm mb-1">Задача</p>
<p className="text-xl font-bold text-neon-400 mb-2"> <p className="text-xl font-bold text-accent-400 mb-2">
{currentAssignment.challenge.title} Пройти игру
</p> </p>
<p className="text-gray-300"> <p className="text-gray-300">
{currentAssignment.challenge.description} {currentAssignment.playthrough_info?.description}
</p> </p>
</div> </div>
<div className="flex items-center gap-3 mb-6 flex-wrap"> <div className="flex items-center gap-3 mb-6 flex-wrap">
<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"> <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">
+{currentAssignment.challenge.points} очков +{currentAssignment.playthrough_info?.points} очков
</span>
</div>
{currentAssignment.playthrough_info?.proof_hint && (
<div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<p className="text-sm text-gray-400">
<strong className="text-white">Нужно доказательство:</strong> {currentAssignment.playthrough_info.proof_hint}
</p>
</div>
)}
{/* Bonus challenges */}
{currentAssignment.bonus_challenges && currentAssignment.bonus_challenges.length > 0 && (
<div className="mb-6 p-4 bg-accent-500/10 rounded-xl border border-accent-500/20">
<p className="text-accent-400 font-medium mb-3">
Бонусные челленджи (опционально) {currentAssignment.bonus_challenges.filter(b => b.status === 'completed').length}/{currentAssignment.bonus_challenges.length}
</p>
<div className="space-y-2">
{currentAssignment.bonus_challenges.map((bonus) => (
<div
key={bonus.id}
className={`rounded-lg border overflow-hidden ${
bonus.status === 'completed'
? 'bg-green-500/10 border-green-500/30'
: 'bg-dark-700/50 border-dark-600'
}`}
>
{/* Bonus header */}
<div
className={`p-3 flex items-center justify-between ${
bonus.status === 'pending' ? 'cursor-pointer hover:bg-dark-600/50' : ''
}`}
onClick={() => {
if (bonus.status === 'pending') {
setExpandedBonusId(expandedBonusId === bonus.id ? null : bonus.id)
setBonusProofFiles([])
setBonusProofUrl('')
setBonusComment('')
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
}
}}
>
<div className="flex-1">
<div className="flex items-center gap-2">
{bonus.status === 'completed' && (
<Check className="w-4 h-4 text-green-400" />
)}
<p className="text-white font-medium text-sm">{bonus.challenge.title}</p>
</div>
<p className="text-gray-400 text-xs mt-0.5">{bonus.challenge.description}</p>
</div>
<div className="text-right shrink-0 ml-2">
{bonus.status === 'completed' ? (
<span className="text-green-400 text-sm font-medium">+{bonus.points_earned}</span>
) : (
<span className="text-accent-400 text-sm">+{bonus.challenge.points}</span>
)}
</div>
</div>
{/* Expanded form for completing */}
{expandedBonusId === bonus.id && bonus.status === 'pending' && (
<div className="p-3 border-t border-dark-600 bg-dark-800/50 space-y-3">
{bonus.challenge.proof_hint && (
<p className="text-xs text-gray-400">
<strong className="text-white">Пруф:</strong> {bonus.challenge.proof_hint}
</p>
)}
{/* File upload */}
<input
ref={bonusFileInputRef}
type="file"
accept="image/*,video/*"
multiple
className="hidden"
onChange={(e) => {
e.stopPropagation()
const files = Array.from(e.target.files || [])
setBonusProofFiles(prev => [...prev, ...files])
e.target.value = ''
}}
/>
{bonusProofFiles.length > 0 ? (
<div className="space-y-2">
{bonusProofFiles.map((file, index) => (
<div key={index} className="flex items-center gap-2 p-2 bg-dark-700/50 rounded-lg border border-dark-600">
<span className="text-white text-sm flex-1 truncate">{file.name}</span>
<button
onClick={(e) => {
e.stopPropagation()
setBonusProofFiles(prev => prev.filter((_, i) => i !== index))
}}
className="p-1 rounded text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
>
<X className="w-3 h-3" />
</button>
</div>
))}
<button
onClick={(e) => {
e.stopPropagation()
bonusFileInputRef.current?.click()
}}
className="w-full p-2 border border-dashed border-neon-500/30 rounded-lg text-neon-400 hover:border-neon-500/50 hover:bg-neon-500/5 transition-all text-sm flex items-center justify-center gap-2"
>
<Upload className="w-4 h-4" />
Добавить еще файл
</button>
</div>
) : (
<button
onClick={(e) => {
e.stopPropagation()
bonusFileInputRef.current?.click()
}}
className="w-full p-2 border border-dashed border-dark-500 rounded-lg text-gray-400 text-sm hover:border-accent-400 hover:text-accent-400 transition-colors flex items-center justify-center gap-2"
>
<Upload className="w-4 h-4" />
Загрузить файл
</button>
)}
<div className="text-center text-gray-500 text-xs">или</div>
<input
type="text"
className="input text-sm"
placeholder="Ссылка на пруф (YouTube, Steam и т.д.)"
value={bonusProofUrl}
onChange={(e) => setBonusProofUrl(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
<textarea
className="input text-sm resize-none"
placeholder="Комментарий (необязательно)"
rows={2}
value={bonusComment}
onChange={(e) => setBonusComment(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex gap-2">
<NeonButton
size="sm"
onClick={(e) => {
e.stopPropagation()
handleBonusComplete(bonus.id)
}}
isLoading={isCompletingBonus}
disabled={bonusProofFiles.length === 0 && !bonusProofUrl && !bonusComment}
icon={<Check className="w-3 h-3" />}
>
Выполнено
</NeonButton>
<NeonButton
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation()
setBonusProofFiles([])
setBonusProofUrl('')
setBonusComment('')
setExpandedBonusId(null)
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
}}
>
Отмена
</NeonButton>
</div>
</div>
)}
</div>
))}
</div>
<p className="text-xs text-gray-500 mt-2">
Нажмите на бонус, чтобы отметить. Очки начислятся при завершении игры.
</p>
</div>
)}
</>
) : (
// Regular challenge
<>
<div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Задание</p>
<p className="text-xl font-bold text-neon-400 mb-2">
{currentAssignment.challenge?.title}
</p>
<p className="text-gray-300">
{currentAssignment.challenge?.description}
</p>
</div>
<div className="flex items-center gap-3 mb-6 flex-wrap">
<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">
+{currentAssignment.challenge?.points} очков
</span> </span>
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600"> <span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
{currentAssignment.challenge.difficulty} {currentAssignment.challenge?.difficulty}
</span> </span>
{currentAssignment.challenge.estimated_time && ( {currentAssignment.challenge?.estimated_time && (
<span className="text-gray-400 text-sm flex items-center gap-1"> <span className="text-gray-400 text-sm flex items-center gap-1">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
~{currentAssignment.challenge.estimated_time} мин ~{currentAssignment.challenge.estimated_time} мин
@@ -928,38 +1213,60 @@ export function PlayPage() {
)} )}
</div> </div>
{currentAssignment.challenge.proof_hint && ( {currentAssignment.challenge?.proof_hint && (
<div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600"> <div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
<strong className="text-white">Нужно доказательство:</strong> {currentAssignment.challenge.proof_hint} <strong className="text-white">Нужно доказательство:</strong> {currentAssignment.challenge.proof_hint}
</p> </p>
</div> </div>
)} )}
</>
)}
<div className="space-y-4 mb-6"> <div className="space-y-4 mb-6">
<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-2">
Загрузить доказательство ({currentAssignment.challenge.proof_type}) Загрузить доказательство ({currentAssignment.is_playthrough
? currentAssignment.playthrough_info?.proof_type
: currentAssignment.challenge?.proof_type})
</label> </label>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="image/*,video/*" accept="image/*,video/*"
multiple
className="hidden" className="hidden"
onChange={(e) => validateAndSetFile(e.target.files?.[0] || null, setProofFile, fileInputRef)} onChange={(e) => {
const files = Array.from(e.target.files || [])
setProofFiles(prev => [...prev, ...files])
// Reset input to allow selecting same files again
e.target.value = ''
}}
/> />
{proofFile ? ( {proofFiles.length > 0 ? (
<div className="flex items-center gap-2 p-3 bg-dark-700/50 rounded-xl border border-dark-600"> <div className="space-y-2">
<span className="text-white flex-1 truncate">{proofFile.name}</span> {proofFiles.map((file, index) => (
<div key={index} className="flex items-center gap-2 p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<span className="text-white flex-1 truncate">{file.name}</span>
<button <button
onClick={() => setProofFile(null)} onClick={() => setProofFiles(proofFiles.filter((_, i) => i !== index))}
className="p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors" className="p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>
))}
<NeonButton
variant="outline"
className="w-full"
onClick={() => fileInputRef.current?.click()}
icon={<Upload className="w-4 h-4" />}
>
Добавить ещё файлы
</NeonButton>
</div>
) : ( ) : (
<div> <div>
<NeonButton <NeonButton
@@ -968,10 +1275,10 @@ export function PlayPage() {
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
icon={<Upload className="w-4 h-4" />} icon={<Upload className="w-4 h-4" />}
> >
Выбрать файл Выбрать файлы
</NeonButton> </NeonButton>
<p className="text-xs text-gray-500 mt-2 text-center"> <p className="text-xs text-gray-500 mt-2 text-center">
Макс. 15 МБ для изображений, 30 МБ для видео Можно выбрать несколько файлов. Макс. 15 МБ для изображений, 30 МБ для видео
</p> </p>
</div> </div>
)} )}
@@ -1000,7 +1307,10 @@ export function PlayPage() {
className="flex-1" className="flex-1"
onClick={handleComplete} onClick={handleComplete}
isLoading={isCompleting} isLoading={isCompleting}
disabled={!proofFile && !proofUrl} disabled={currentAssignment.is_playthrough
? (proofFiles.length === 0 && !proofUrl && !comment)
: (proofFiles.length === 0 && !proofUrl)
}
icon={<Check className="w-4 h-4" />} icon={<Check className="w-4 h-4" />}
> >
Выполнено Выполнено

View File

@@ -0,0 +1,107 @@
import { useState, useEffect } from 'react'
import { useParams, useLocation, Link } from 'react-router-dom'
import { contentApi } from '@/api/admin'
import type { StaticContent } from '@/types'
import { GlassCard } from '@/components/ui'
import { ArrowLeft, Loader2, FileText } from 'lucide-react'
// Map routes to content keys
const ROUTE_KEY_MAP: Record<string, string> = {
'/terms': 'terms_of_service',
'/privacy': 'privacy_policy',
}
export function StaticContentPage() {
const { key: paramKey } = useParams<{ key: string }>()
const location = useLocation()
const [content, setContent] = useState<StaticContent | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Determine content key from route or param
const contentKey = ROUTE_KEY_MAP[location.pathname] || paramKey
useEffect(() => {
if (!contentKey) return
const loadContent = async () => {
setIsLoading(true)
setError(null)
try {
const data = await contentApi.getPublicContent(contentKey)
setContent(data)
} catch {
setError('Контент не найден')
} finally {
setIsLoading(false)
}
}
loadContent()
}, [contentKey])
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 || !content) {
return (
<div className="max-w-3xl mx-auto">
<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">
<FileText className="w-10 h-10 text-gray-600" />
</div>
<h3 className="text-xl font-semibold text-white mb-2">Страница не найдена</h3>
<p className="text-gray-400 mb-6">Запрашиваемый контент не существует</p>
<Link
to="/"
className="inline-flex items-center gap-2 text-neon-400 hover:text-neon-300 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
На главную
</Link>
</GlassCard>
</div>
)
}
return (
<div className="max-w-3xl mx-auto">
<Link
to="/"
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>
<GlassCard>
<h1 className="text-2xl md:text-3xl font-bold text-white mb-6">{content.title}</h1>
<div
className="prose prose-invert prose-gray max-w-none
prose-headings:text-white prose-headings:font-semibold
prose-p:text-gray-300 prose-p:leading-relaxed
prose-a:text-neon-400 prose-a:no-underline hover:prose-a:text-neon-300
prose-strong:text-white
prose-ul:text-gray-300 prose-ol:text-gray-300
prose-li:marker:text-gray-500
prose-hr:border-dark-600 prose-hr:my-6
prose-img:rounded-xl prose-img:shadow-lg"
dangerouslySetInnerHTML={{ __html: content.content }}
/>
<div className="mt-8 pt-6 border-t border-dark-600 text-sm text-gray-500">
Последнее обновление: {new Date(content.updated_at).toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric'
})}
</div>
</GlassCard>
</div>
)
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,312 @@
import { useState, useEffect } from 'react'
import { adminApi } from '@/api'
import type { AdminDispute } from '@/types'
import { GlassCard, NeonButton } from '@/components/ui'
import { useToast } from '@/store/toast'
import {
AlertTriangle, Loader2, CheckCircle, XCircle, Clock,
ThumbsUp, ThumbsDown, User, Trophy, ExternalLink
} from 'lucide-react'
import { Link } from 'react-router-dom'
export function AdminDisputesPage() {
const toast = useToast()
const [disputes, setDisputes] = useState<AdminDispute[]>([])
const [isLoading, setIsLoading] = useState(true)
const [filter, setFilter] = useState<'pending' | 'open' | 'all'>('pending')
const [resolvingId, setResolvingId] = useState<number | null>(null)
useEffect(() => {
loadDisputes()
}, [filter])
const loadDisputes = async () => {
setIsLoading(true)
try {
const data = await adminApi.listDisputes(filter)
setDisputes(data)
} catch (err) {
toast.error('Не удалось загрузить оспаривания')
} finally {
setIsLoading(false)
}
}
const handleResolve = async (disputeId: number, isValid: boolean) => {
setResolvingId(disputeId)
try {
await adminApi.resolveDispute(disputeId, isValid)
toast.success(isValid ? 'Пруф подтверждён' : 'Пруф отклонён')
await loadDisputes()
} catch (err) {
toast.error('Не удалось разрешить диспут')
} finally {
setResolvingId(null)
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString('ru-RU', {
day: 'numeric',
month: 'short',
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 getStatusBadge = (status: string) => {
switch (status) {
case 'open':
return (
<span className="px-2 py-1 bg-blue-500/20 text-blue-400 rounded text-xs font-medium flex items-center gap-1">
<Clock className="w-3 h-3" />
Голосование
</span>
)
case 'pending_admin':
return (
<span className="px-2 py-1 bg-orange-500/20 text-orange-400 rounded text-xs font-medium flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Ожидает решения
</span>
)
case 'valid':
return (
<span className="px-2 py-1 bg-green-500/20 text-green-400 rounded text-xs font-medium flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Валидно
</span>
)
case 'invalid':
return (
<span className="px-2 py-1 bg-red-500/20 text-red-400 rounded text-xs font-medium flex items-center gap-1">
<XCircle className="w-3 h-3" />
Невалидно
</span>
)
}
}
const pendingCount = disputes.filter(d => d.status === 'pending_admin').length
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 to-orange-400">
Оспаривания
</h1>
<p className="text-gray-400 mt-1">
Управление диспутами и проверка пруфов
</p>
</div>
{pendingCount > 0 && (
<div className="px-4 py-2 bg-orange-500/20 border border-orange-500/30 rounded-xl">
<span className="text-orange-400 font-semibold">{pendingCount}</span>
<span className="text-gray-400 ml-2">ожида{pendingCount === 1 ? 'ет' : 'ют'} решения</span>
</div>
)}
</div>
{/* Filters */}
<div className="flex gap-2">
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${
filter === 'pending'
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
}`}
onClick={() => setFilter('pending')}
>
Ожидают решения
</button>
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${
filter === 'open'
? 'bg-blue-500/20 text-blue-400 border border-blue-500/30'
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
}`}
onClick={() => setFilter('open')}
>
Голосование
</button>
<button
className={`px-4 py-2 rounded-lg font-medium transition-all ${
filter === 'all'
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30'
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
}`}
onClick={() => setFilter('all')}
>
Все
</button>
</div>
{/* Loading */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-accent-500" />
</div>
) : disputes.length === 0 ? (
<GlassCard className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-green-500/10 border border-green-500/30 flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-green-400" />
</div>
<p className="text-gray-400">
{filter === 'pending' ? 'Нет оспариваний, ожидающих решения' :
filter === 'open' ? 'Нет оспариваний в стадии голосования' :
'Нет оспариваний'}
</p>
</GlassCard>
) : (
<div className="space-y-4">
{disputes.map((dispute) => (
<GlassCard
key={dispute.id}
className={
dispute.status === 'pending_admin' ? 'border-orange-500/30' :
dispute.status === 'open' ? 'border-blue-500/30' : ''
}
>
<div className="flex items-start justify-between gap-4">
{/* Left side - Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center shrink-0">
<AlertTriangle className="w-5 h-5 text-yellow-400" />
</div>
<div className="min-w-0">
<h3 className="text-white font-semibold truncate">
{dispute.challenge_title}
</h3>
<div className="flex items-center gap-2 text-sm text-gray-400">
<Trophy className="w-3 h-3" />
<span className="truncate">{dispute.marathon_title}</span>
</div>
</div>
</div>
{/* Participants */}
<div className="flex flex-wrap gap-4 mb-3 text-sm">
<div className="flex items-center gap-1.5">
<User className="w-4 h-4 text-gray-500" />
<span className="text-gray-400">Автор:</span>
<span className="text-white">{dispute.participant_nickname}</span>
</div>
<div className="flex items-center gap-1.5">
<AlertTriangle className="w-4 h-4 text-gray-500" />
<span className="text-gray-400">Оспорил:</span>
<span className="text-white">{dispute.raised_by_nickname}</span>
</div>
</div>
{/* Reason */}
<div className="p-3 bg-dark-700/50 rounded-lg border border-dark-600 mb-3">
<p className="text-sm text-gray-400 mb-1">Причина:</p>
<p className="text-white text-sm">{dispute.reason}</p>
</div>
{/* Votes & Time */}
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 text-green-400">
<ThumbsUp className="w-4 h-4" />
<span className="font-medium">{dispute.votes_valid}</span>
</div>
<span className="text-gray-600">/</span>
<div className="flex items-center gap-1 text-red-400">
<ThumbsDown className="w-4 h-4" />
<span className="font-medium">{dispute.votes_invalid}</span>
</div>
</div>
<span className="text-gray-600"></span>
<span className="text-gray-400">{formatDate(dispute.created_at)}</span>
{dispute.status === 'open' && (
<>
<span className="text-gray-600"></span>
<span className="text-yellow-400 flex items-center gap-1">
<Clock className="w-3 h-3" />
{getTimeRemaining(dispute.expires_at)}
</span>
</>
)}
</div>
</div>
{/* Right side - Status & Actions */}
<div className="flex flex-col items-end gap-3 shrink-0">
{getStatusBadge(dispute.status)}
{/* Link to assignment */}
{dispute.assignment_id && (
<Link
to={`/assignments/${dispute.assignment_id}`}
className="text-xs text-accent-400 hover:text-accent-300 flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
Открыть
</Link>
)}
{/* Resolution buttons - show for open and pending_admin */}
{(dispute.status === 'open' || dispute.status === 'pending_admin') && (
<div className="flex flex-col gap-2">
{/* Vote recommendation for pending disputes */}
{dispute.status === 'pending_admin' && (
<div className="text-xs text-gray-400 text-right mb-1">
Рекомендация: {dispute.votes_invalid > dispute.votes_valid ? (
<span className="text-red-400">невалидно</span>
) : (
<span className="text-green-400">валидно</span>
)}
</div>
)}
<div className="flex gap-2">
<NeonButton
size="sm"
variant="outline"
className="border-green-500/50 text-green-400 hover:bg-green-500/10"
onClick={() => handleResolve(dispute.id, true)}
isLoading={resolvingId === dispute.id}
disabled={resolvingId !== null}
icon={<CheckCircle className="w-4 h-4" />}
>
Валидно
</NeonButton>
<NeonButton
size="sm"
variant="outline"
className="border-red-500/50 text-red-400 hover:bg-red-500/10"
onClick={() => handleResolve(dispute.id, false)}
isLoading={resolvingId === dispute.id}
disabled={resolvingId !== null}
icon={<XCircle className="w-4 h-4" />}
>
Невалидно
</NeonButton>
</div>
</div>
)}
</div>
</div>
</GlassCard>
))}
</div>
)}
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,23 @@ import { persist } from 'zustand/middleware'
import type { User } from '@/types' import type { User } from '@/types'
import { authApi, type RegisterData, type LoginData } from '@/api/auth' import { authApi, type RegisterData, type LoginData } from '@/api/auth'
let syncPromise: Promise<void> | null = null
interface Pending2FA {
sessionId: number
}
interface LoginResult {
requires2FA: boolean
sessionId?: number
}
export interface BanInfo {
banned_at: string | null
banned_until: string | null
reason: string | null
}
interface AuthState { interface AuthState {
user: User | null user: User | null
token: string | null token: string | null
@@ -11,8 +28,12 @@ interface AuthState {
error: string | null error: string | null
pendingInviteCode: string | null pendingInviteCode: string | null
avatarVersion: number avatarVersion: number
pending2FA: Pending2FA | null
banInfo: BanInfo | null
login: (data: LoginData) => Promise<void> login: (data: LoginData) => Promise<LoginResult>
verify2FA: (code: string) => Promise<void>
cancel2FA: () => void
register: (data: RegisterData) => Promise<void> register: (data: RegisterData) => Promise<void>
logout: () => void logout: () => void
clearError: () => void clearError: () => void
@@ -20,6 +41,9 @@ interface AuthState {
consumePendingInviteCode: () => string | null consumePendingInviteCode: () => string | null
updateUser: (updates: Partial<User>) => void updateUser: (updates: Partial<User>) => void
bumpAvatarVersion: () => void bumpAvatarVersion: () => void
setBanned: (banInfo: BanInfo) => void
clearBanned: () => void
syncUser: () => Promise<void>
} }
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
@@ -32,11 +56,25 @@ export const useAuthStore = create<AuthState>()(
error: null, error: null,
pendingInviteCode: null, pendingInviteCode: null,
avatarVersion: 0, avatarVersion: 0,
pending2FA: null,
banInfo: null,
login: async (data) => { login: async (data) => {
set({ isLoading: true, error: null }) set({ isLoading: true, error: null, pending2FA: null, banInfo: null })
try { try {
const response = await authApi.login(data) const response = await authApi.login(data)
// Check if 2FA is required
if (response.requires_2fa && response.two_factor_session_id) {
set({
isLoading: false,
pending2FA: { sessionId: response.two_factor_session_id },
})
return { requires2FA: true, sessionId: response.two_factor_session_id }
}
// Regular login (no 2FA)
if (response.access_token && response.user) {
localStorage.setItem('token', response.access_token) localStorage.setItem('token', response.access_token)
set({ set({
user: response.user, user: response.user,
@@ -44,16 +82,74 @@ export const useAuthStore = create<AuthState>()(
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
}) })
}
return { requires2FA: false }
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } } const error = err as { response?: { status?: number; data?: { detail?: string | BanInfo } } }
// Check if user is banned (403 with ban info)
if (error.response?.status === 403) {
const detail = error.response?.data?.detail
if (typeof detail === 'object' && detail !== null && 'banned_at' in detail) {
set({ set({
error: error.response?.data?.detail || 'Login failed', banInfo: detail as BanInfo,
isLoading: false,
error: null,
})
throw err
}
}
// Regular error - translate common messages
let errorMessage = 'Ошибка входа'
const detail = error.response?.data?.detail
if (typeof detail === 'string') {
if (detail === 'Incorrect login or password') {
errorMessage = 'Неверный логин или пароль'
} else {
errorMessage = detail
}
}
set({
error: errorMessage,
isLoading: false, isLoading: false,
}) })
throw err throw err
} }
}, },
verify2FA: async (code) => {
const pending = get().pending2FA
if (!pending) {
throw new Error('No pending 2FA session')
}
set({ isLoading: true, error: null })
try {
const response = await authApi.verify2FA(pending.sessionId, code)
localStorage.setItem('token', response.access_token)
set({
user: response.user,
token: response.access_token,
isAuthenticated: true,
isLoading: false,
pending2FA: null,
})
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
set({
error: error.response?.data?.detail || '2FA verification failed',
isLoading: false,
})
throw err
}
},
cancel2FA: () => {
set({ pending2FA: null, error: null })
},
register: async (data) => { register: async (data) => {
set({ isLoading: true, error: null }) set({ isLoading: true, error: null })
try { try {
@@ -77,10 +173,12 @@ export const useAuthStore = create<AuthState>()(
logout: () => { logout: () => {
localStorage.removeItem('token') localStorage.removeItem('token')
sessionStorage.removeItem('telegram_banner_dismissed')
set({ set({
user: null, user: null,
token: null, token: null,
isAuthenticated: false, isAuthenticated: false,
banInfo: null,
}) })
}, },
@@ -104,6 +202,35 @@ export const useAuthStore = create<AuthState>()(
bumpAvatarVersion: () => { bumpAvatarVersion: () => {
set({ avatarVersion: get().avatarVersion + 1 }) set({ avatarVersion: get().avatarVersion + 1 })
}, },
setBanned: (banInfo) => {
set({ banInfo })
},
clearBanned: () => {
set({ banInfo: null })
},
syncUser: async () => {
if (!get().isAuthenticated || !get().token) return
// Prevent duplicate sync calls
if (syncPromise) return syncPromise
syncPromise = (async () => {
try {
const userData = await authApi.me()
set({ user: userData })
} catch {
// Token invalid - logout
get().logout()
} finally {
syncPromise = null
}
})()
return syncPromise
},
}), }),
{ {
name: 'auth-storage', name: 'auth-storage',

View File

@@ -26,6 +26,15 @@ export interface TokenResponse {
user: User user: User
} }
// Login response (may require 2FA for admins)
export interface LoginResponse {
access_token?: string | null
token_type: string
user?: User | null
requires_2fa: boolean
two_factor_session_id?: number | null
}
// Marathon types // Marathon types
export type MarathonStatus = 'preparing' | 'active' | 'finished' export type MarathonStatus = 'preparing' | 'active' | 'finished'
export type ParticipantRole = 'participant' | 'organizer' export type ParticipantRole = 'participant' | 'organizer'
@@ -54,6 +63,7 @@ export interface Marathon {
is_public: boolean is_public: boolean
game_proposal_mode: GameProposalMode game_proposal_mode: GameProposalMode
auto_events_enabled: boolean auto_events_enabled: boolean
cover_url: string | null
start_date: string | null start_date: string | null
end_date: string | null end_date: string | null
participants_count: number participants_count: number
@@ -67,6 +77,7 @@ export interface MarathonListItem {
title: string title: string
status: MarathonStatus status: MarathonStatus
is_public: boolean is_public: boolean
cover_url: string | null
participants_count: number participants_count: number
start_date: string | null start_date: string | null
end_date: string | null end_date: string | null
@@ -81,11 +92,21 @@ export interface MarathonCreate {
game_proposal_mode: GameProposalMode game_proposal_mode: GameProposalMode
} }
export interface MarathonUpdate {
title?: string
description?: string
start_date?: string
is_public?: boolean
game_proposal_mode?: GameProposalMode
auto_events_enabled?: boolean
}
export interface MarathonPublicInfo { export interface MarathonPublicInfo {
id: number id: number
title: string title: string
description: string | null description: string | null
status: MarathonStatus status: MarathonStatus
cover_url: string | null
participants_count: number participants_count: number
creator_nickname: string creator_nickname: string
} }
@@ -101,6 +122,14 @@ export interface LeaderboardEntry {
// Game types // Game types
export type GameStatus = 'pending' | 'approved' | 'rejected' export type GameStatus = 'pending' | 'approved' | 'rejected'
export type GameType = 'challenges' | 'playthrough'
export interface PlaythroughInfo {
description: string
points: number
proof_type: ProofType
proof_hint: string | null
}
export interface Game { export interface Game {
id: number id: number
@@ -113,12 +142,25 @@ export interface Game {
approved_by: User | null approved_by: User | null
challenges_count: number challenges_count: number
created_at: string created_at: string
// Game type fields
game_type: GameType
playthrough_points: number | null
playthrough_description: string | null
playthrough_proof_type: ProofType | null
playthrough_proof_hint: string | null
} }
export interface GameShort { export interface GameShort {
id: number id: number
title: string title: string
cover_url: string | null cover_url: string | null
download_url?: string
game_type?: GameType
}
export interface AvailableGamesCount {
available: number
total: number
} }
// Challenge types // Challenge types
@@ -135,6 +177,13 @@ export type ChallengeType =
export type Difficulty = 'easy' | 'medium' | 'hard' export type Difficulty = 'easy' | 'medium' | 'hard'
export type ProofType = 'screenshot' | 'video' | 'steam' export type ProofType = 'screenshot' | 'video' | 'steam'
export type ChallengeStatus = 'pending' | 'approved' | 'rejected'
export interface ProposedByUser {
id: number
nickname: string
}
export interface Challenge { export interface Challenge {
id: number id: number
game: GameShort game: GameShort
@@ -148,6 +197,8 @@ export interface Challenge {
proof_hint: string | null proof_hint: string | null
is_generated: boolean is_generated: boolean
created_at: string created_at: string
status: ChallengeStatus
proposed_by: ProposedByUser | null
} }
export interface ChallengePreview { export interface ChallengePreview {
@@ -169,10 +220,28 @@ export interface ChallengesPreviewResponse {
// Assignment types // Assignment types
export type AssignmentStatus = 'active' | 'completed' | 'dropped' | 'returned' export type AssignmentStatus = 'active' | 'completed' | 'dropped' | 'returned'
export type BonusAssignmentStatus = 'pending' | 'completed'
export interface BonusAssignment {
id: number
challenge: Challenge
status: BonusAssignmentStatus
proof_url: string | null
proof_image_url: string | null // Legacy, for backward compatibility
proof_files?: ProofFile[] // Multiple uploaded files
proof_comment: string | null
points_earned: number
completed_at: string | null
can_dispute?: boolean
dispute?: Dispute | null
}
export interface Assignment { export interface Assignment {
id: number id: number
challenge: Challenge challenge: Challenge | null // null for playthrough
game?: GameShort // For playthrough
is_playthrough?: boolean
playthrough_info?: PlaythroughInfo // For playthrough
status: AssignmentStatus status: AssignmentStatus
proof_url: string | null proof_url: string | null
proof_comment: string | null proof_comment: string | null
@@ -181,12 +250,16 @@ export interface Assignment {
started_at: string started_at: string
completed_at: string | null completed_at: string | null
drop_penalty: number drop_penalty: number
bonus_challenges?: BonusAssignment[] // For playthrough
} }
export interface SpinResult { export interface SpinResult {
assignment_id: number assignment_id: number
game: Game game: Game
challenge: Challenge challenge: Challenge | null // null for playthrough
is_playthrough?: boolean
playthrough_info?: PlaythroughInfo // For playthrough
bonus_challenges?: Challenge[] // Available bonus challenges for playthrough
can_drop: boolean can_drop: boolean
drop_penalty: number drop_penalty: number
} }
@@ -395,6 +468,10 @@ export interface AdminUser {
telegram_username: string | null telegram_username: string | null
marathons_count: number marathons_count: number
created_at: string created_at: string
is_banned: boolean
banned_at: string | null
banned_until: string | null // null = permanent ban
ban_reason: string | null
} }
export interface AdminMarathon { export interface AdminMarathon {
@@ -416,8 +493,100 @@ export interface PlatformStats {
total_participations: number total_participations: number
} }
// Admin action log types
export type AdminActionType =
| 'user_ban'
| 'user_unban'
| 'user_role_change'
| 'marathon_force_finish'
| 'marathon_delete'
| 'content_update'
| 'broadcast_all'
| 'broadcast_marathon'
| 'admin_login'
| 'admin_2fa_success'
| 'admin_2fa_fail'
export interface AdminLog {
id: number
admin_id: number
admin_nickname: string
action: AdminActionType
target_type: string
target_id: number
details: Record<string, unknown> | null
ip_address: string | null
created_at: string
}
export interface AdminLogsResponse {
logs: AdminLog[]
total: number
}
// Broadcast types
export interface BroadcastResponse {
sent_count: number
total_count: number
}
// Static content types
export interface StaticContent {
id: number
key: string
title: string
content: string
updated_at: string
created_at: string
}
// Dashboard stats
export interface DashboardStats {
users_count: number
banned_users_count: number
marathons_count: number
active_marathons_count: number
games_count: number
total_participations: number
recent_logs: AdminLog[]
}
// Admin dispute
export interface AdminDispute {
id: number
assignment_id: number | null
bonus_assignment_id: number | null
marathon_id: number
marathon_title: string
challenge_title: string
participant_nickname: string
raised_by_nickname: string
reason: string
status: DisputeStatus
votes_valid: number
votes_invalid: number
created_at: string
expires_at: string
}
// Marathon organizer dispute
export interface MarathonDispute {
id: number
assignment_id: number | null
bonus_assignment_id: number | null
challenge_title: string
participant_nickname: string
raised_by_nickname: string
reason: string
status: DisputeStatus
votes_valid: number
votes_invalid: number
created_at: string
expires_at: string
}
// Dispute types // Dispute types
export type DisputeStatus = 'open' | 'valid' | 'invalid' export type DisputeStatus = 'open' | 'pending_admin' | 'valid' | 'invalid'
export interface DisputeComment { export interface DisputeComment {
id: number id: number
@@ -447,13 +616,24 @@ export interface Dispute {
resolved_at: string | null resolved_at: string | null
} }
export interface ProofFile {
id: number
file_type: 'image' | 'video'
order_index: number
created_at: string
}
export interface AssignmentDetail { export interface AssignmentDetail {
id: number id: number
challenge: Challenge challenge: Challenge | null // null for playthrough
game?: GameShort // for playthrough
is_playthrough: boolean
playthrough_info?: PlaythroughInfo // for playthrough
participant: User participant: User
status: AssignmentStatus status: AssignmentStatus
proof_url: string | null proof_url: string | null
proof_image_url: string | null proof_image_url: string | null // Legacy, for backward compatibility
proof_files: ProofFile[] // Multiple uploaded files
proof_comment: string | null proof_comment: string | null
points_earned: number points_earned: number
streak_at_completion: number | null streak_at_completion: number | null
@@ -461,11 +641,16 @@ export interface AssignmentDetail {
completed_at: string | null completed_at: string | null
can_dispute: boolean can_dispute: boolean
dispute: Dispute | null dispute: Dispute | null
bonus_challenges?: BonusAssignment[] // for playthrough
} }
export interface ReturnedAssignment { export interface ReturnedAssignment {
id: number id: number
challenge: Challenge challenge: Challenge | null // For challenge assignments
is_playthrough: boolean
game_id: number | null // For playthrough assignments
game_title: string | null
game_cover_url: string | null
original_completed_at: string original_completed_at: string
dispute_reason: string dispute_reason: string
} }

View File

@@ -0,0 +1,123 @@
// Keyboard layout mapping (RU -> EN and EN -> RU)
const ruToEn: Record<string, string> = {
'й': 'q', 'ц': 'w', 'у': 'e', 'к': 'r', 'е': 't', 'н': 'y', 'г': 'u', 'ш': 'i', 'щ': 'o', 'з': 'p',
'ф': 'a', 'ы': 's', 'в': 'd', 'а': 'f', 'п': 'g', 'р': 'h', 'о': 'j', 'л': 'k', 'д': 'l',
'я': 'z', 'ч': 'x', 'с': 'c', 'м': 'v', 'и': 'b', 'т': 'n', 'ь': 'm',
'х': '[', 'ъ': ']', 'ж': ';', 'э': "'", 'б': ',', 'ю': '.',
}
const enToRu: Record<string, string> = Object.fromEntries(
Object.entries(ruToEn).map(([ru, en]) => [en, ru])
)
function convertLayout(text: string): string {
return text
.split('')
.map(char => {
const lower = char.toLowerCase()
const converted = ruToEn[lower] || enToRu[lower] || char
return char === lower ? converted : converted.toUpperCase()
})
.join('')
}
function levenshteinDistance(a: string, b: string): number {
const matrix: number[][] = []
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i]
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1]
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1, // substitution
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j] + 1 // deletion
)
}
}
}
return matrix[b.length][a.length]
}
export interface FuzzyMatch<T> {
item: T
score: number
}
export function fuzzySearch<T>(
items: T[],
query: string,
getSearchField: (item: T) => string
): FuzzyMatch<T>[] {
if (!query.trim()) {
return items.map(item => ({ item, score: 1 }))
}
const normalizedQuery = query.toLowerCase().trim()
const convertedQuery = convertLayout(normalizedQuery)
const results: FuzzyMatch<T>[] = []
for (const item of items) {
const field = getSearchField(item).toLowerCase()
// Exact substring match - highest score
if (field.includes(normalizedQuery)) {
results.push({ item, score: 1 })
continue
}
// Converted layout match
if (field.includes(convertedQuery)) {
results.push({ item, score: 0.95 })
continue
}
// Check if query matches start of words
const words = field.split(/\s+/)
const queryWords = normalizedQuery.split(/\s+/)
const startsWithMatch = queryWords.every(qw =>
words.some(w => w.startsWith(qw))
)
if (startsWithMatch) {
results.push({ item, score: 0.9 })
continue
}
// Levenshtein distance for typo tolerance
const distance = levenshteinDistance(normalizedQuery, field)
const maxLen = Math.max(normalizedQuery.length, field.length)
const similarity = 1 - distance / maxLen
// Also check against converted query
const convertedDistance = levenshteinDistance(convertedQuery, field)
const convertedSimilarity = 1 - convertedDistance / maxLen
const bestSimilarity = Math.max(similarity, convertedSimilarity)
// Only include if similarity is reasonable (> 40%)
if (bestSimilarity > 0.4) {
results.push({ item, score: bestSimilarity * 0.8 })
}
}
// Sort by score descending
return results.sort((a, b) => b.score - a.score)
}
export function fuzzyFilter<T>(
items: T[],
query: string,
getSearchField: (item: T) => string
): T[] {
return fuzzySearch(items, query, getSearchField).map(r => r.item)
}

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