Compare commits

31 Commits

Author SHA1 Message Date
ca49e42f74 Fix common enemy 2026-01-08 05:29:55 +07:00
18fe95effc Fix events 2026-01-05 23:41:22 +07:00
65b2512d8c Add upload images 2026-01-04 04:58:41 +07:00
81d992abe6 remake send push systems 2026-01-04 04:16:54 +07:00
9014d5d79d Add notification status to users table in AP 2026-01-04 03:42:11 +07:00
18ffff5473 Fix games list 2026-01-04 03:17:17 +07:00
475e2cf4cd Add notification settings 2026-01-04 02:47:38 +07:00
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
115 changed files with 15183 additions and 1234 deletions

View File

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

View File

@@ -1,4 +1,4 @@
.PHONY: help dev up down build build-no-cache logs restart clean migrate shell db-shell frontend-shell backend-shell lint test
.PHONY: help dev up down build build-no-cache logs logs-bot restart clean migrate shell db-shell frontend-shell backend-shell lint test
DC = sudo docker-compose
@@ -14,6 +14,7 @@ help:
@echo " make logs - Show logs (all services)"
@echo " make logs-b - Show backend logs"
@echo " make logs-f - Show frontend logs"
@echo " make logs-bot - Show Telegram bot logs"
@echo ""
@echo " Build:"
@echo " make build - Build all containers (with cache)"
@@ -63,6 +64,9 @@ logs-b:
logs-f:
$(DC) logs -f frontend
logs-bot:
$(DC) logs -f bot
# Build
build:
$(DC) build

View File

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

View File

@@ -9,6 +9,7 @@ from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
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
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:
# Add role column to users table
op.add_column('users', sa.Column('role', sa.String(20), nullable=False, server_default='user'))
if not column_exists('users', 'role'):
op.add_column('users', sa.Column('role', sa.String(20), nullable=False, server_default='user'))
# Add role column to participants table
op.add_column('participants', sa.Column('role', sa.String(20), nullable=False, server_default='participant'))
if not column_exists('participants', 'role'):
op.add_column('participants', sa.Column('role', sa.String(20), nullable=False, server_default='participant'))
# Rename organizer_id to creator_id in marathons table
op.alter_column('marathons', 'organizer_id', new_column_name='creator_id')
if column_exists('marathons', 'organizer_id') and not column_exists('marathons', 'creator_id'):
op.alter_column('marathons', 'organizer_id', new_column_name='creator_id')
# Update existing participants: set role='organizer' for marathon creators
# This is idempotent - running multiple times is safe
op.execute("""
UPDATE participants p
SET role = 'organizer'
@@ -36,37 +55,48 @@ def upgrade() -> None:
""")
# Add status column to games table
op.add_column('games', sa.Column('status', sa.String(20), nullable=False, server_default='approved'))
if not column_exists('games', 'status'):
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
op.alter_column('games', 'added_by_id', new_column_name='proposed_by_id')
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')
# Add approved_by_id column to games table
op.add_column('games', sa.Column('approved_by_id', sa.Integer(), nullable=True))
op.create_foreign_key(
'fk_games_approved_by_id',
'games', 'users',
['approved_by_id'], ['id'],
ondelete='SET NULL'
)
if not column_exists('games', 'approved_by_id'):
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(
'fk_games_approved_by_id',
'games', 'users',
['approved_by_id'], ['id'],
ondelete='SET NULL'
)
def downgrade() -> None:
# Remove approved_by_id from games
op.drop_constraint('fk_games_approved_by_id', 'games', type_='foreignkey')
op.drop_column('games', 'approved_by_id')
if constraint_exists('games', 'fk_games_approved_by_id'):
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')
# Rename proposed_by_id back to added_by_id
op.alter_column('games', 'proposed_by_id', new_column_name='added_by_id')
if column_exists('games', 'proposed_by_id'):
op.alter_column('games', 'proposed_by_id', new_column_name='added_by_id')
# Remove status from games
op.drop_column('games', 'status')
if column_exists('games', 'status'):
op.drop_column('games', 'status')
# Rename creator_id back to organizer_id
op.alter_column('marathons', 'creator_id', new_column_name='organizer_id')
if column_exists('marathons', 'creator_id'):
op.alter_column('marathons', 'creator_id', new_column_name='organizer_id')
# Remove role from participants
op.drop_column('participants', 'role')
if column_exists('participants', 'role'):
op.drop_column('participants', 'role')
# Remove role from users
op.drop_column('users', 'role')
if column_exists('users', 'role'):
op.drop_column('users', 'role')

View File

@@ -9,6 +9,7 @@ from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
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
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:
# Add is_public column to marathons table (default False = private)
op.add_column('marathons', sa.Column('is_public', sa.Boolean(), nullable=False, server_default='false'))
if not column_exists('marathons', 'is_public'):
op.add_column('marathons', sa.Column('is_public', sa.Boolean(), nullable=False, server_default='false'))
# Add game_proposal_mode column to marathons table
# 'all_participants' - anyone can propose games (with moderation)
# 'organizer_only' - only organizers can add games
op.add_column('marathons', sa.Column('game_proposal_mode', sa.String(20), nullable=False, server_default='all_participants'))
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'))
def downgrade() -> None:
op.drop_column('marathons', 'game_proposal_mode')
op.drop_column('marathons', 'is_public')
if column_exists('marathons', 'game_proposal_mode'):
op.drop_column('marathons', 'game_proposal_mode')
if column_exists('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)
op.execute(f"""
INSERT INTO users (login, password_hash, nickname, role, created_at)
VALUES ('admin', '{password_hash}', 'Admin', 'admin', NOW())
INSERT INTO users (login, password_hash, nickname, role, is_banned, created_at)
VALUES ('admin', '{password_hash}', 'Admin', 'admin', false, NOW())
ON CONFLICT (login) DO UPDATE SET
password_hash = '{password_hash}',
role = 'admin'

View File

@@ -17,15 +17,17 @@ depends_on = None
def upgrade() -> None:
# 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'")
# Update event_type in assignments table
op.execute("UPDATE assignments SET event_type = 'game_choice' WHERE event_type = 'rematch'")
# Update activity data that references rematch event
# Cast JSON to JSONB, apply jsonb_set, then cast back to JSON
op.execute("""
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'
""")
@@ -36,6 +38,6 @@ def downgrade() -> None:
op.execute("UPDATE assignments SET event_type = 'rematch' WHERE event_type = 'game_choice'")
op.execute("""
UPDATE activities
SET data = jsonb_set(data, '{event_type}', '"rematch"')
SET data = jsonb_set(data::jsonb, '{event_type}', '"rematch"')::json
WHERE data->>'event_type' = 'game_choice'
""")

View File

@@ -9,6 +9,7 @@ from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# 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
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:
op.add_column('users', sa.Column('telegram_first_name', sa.String(100), nullable=True))
op.add_column('users', sa.Column('telegram_last_name', sa.String(100), nullable=True))
op.add_column('users', sa.Column('telegram_avatar_url', sa.String(500), nullable=True))
if not column_exists('users', 'telegram_first_name'):
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))
if not column_exists('users', 'telegram_avatar_url'):
op.add_column('users', sa.Column('telegram_avatar_url', sa.String(500), nullable=True))
def downgrade() -> None:
op.drop_column('users', 'telegram_avatar_url')
op.drop_column('users', 'telegram_last_name')
op.drop_column('users', 'telegram_first_name')
if column_exists('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')
if column_exists('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

@@ -0,0 +1,45 @@
"""Add notification settings to users
Revision ID: 022_add_notification_settings
Revises: 021_add_bonus_disputes
Create Date: 2025-01-04
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision: str = '022_add_notification_settings'
down_revision: Union[str, None] = '021_add_bonus_disputes'
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:
# Add notification settings (all enabled by default)
if not column_exists('users', 'notify_events'):
op.add_column('users', sa.Column('notify_events', sa.Boolean(), server_default='true', nullable=False))
if not column_exists('users', 'notify_disputes'):
op.add_column('users', sa.Column('notify_disputes', sa.Boolean(), server_default='true', nullable=False))
if not column_exists('users', 'notify_moderation'):
op.add_column('users', sa.Column('notify_moderation', sa.Boolean(), server_default='true', nullable=False))
def downgrade() -> None:
if column_exists('users', 'notify_moderation'):
op.drop_column('users', 'notify_moderation')
if column_exists('users', 'notify_disputes'):
op.drop_column('users', 'notify_disputes')
if column_exists('users', 'notify_events'):
op.drop_column('users', 'notify_events')

View File

@@ -1,4 +1,5 @@
from typing import Annotated
from datetime import datetime
from fastapi import Depends, HTTPException, status, Header
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.database import get_db
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()
@@ -43,6 +44,50 @@ async def get_current_user(
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
@@ -56,6 +101,21 @@ def require_admin(user: User) -> 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(
db: AsyncSession,
user_id: int,

View File

@@ -1,6 +1,6 @@
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")
@@ -15,3 +15,4 @@ router.include_router(admin.router)
router.include_router(events.router)
router.include_router(assignments.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 sqlalchemy import select
from app.api.deps import DbSession, CurrentUser
from app.core.security import verify_password, get_password_hash, create_access_token
from app.core.rate_limit import limiter
from app.models import User
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate
from app.models import User, UserRole, Admin2FASession
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate, LoginResponse
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -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")
async def login(request: Request, data: UserLogin, db: DbSession):
# Find user
@@ -53,6 +57,105 @@ async def login(request: Request, data: UserLogin, db: DbSession):
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
access_token = create_access_token(subject=user.id)

View File

@@ -3,7 +3,8 @@ from sqlalchemy import select
from sqlalchemy.orm import selectinload
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 (
ChallengeCreate,
ChallengeUpdate,
@@ -15,7 +16,9 @@ from app.schemas import (
ChallengesSaveRequest,
ChallengesGenerateRequest,
)
from app.schemas.challenge import ChallengePropose, ProposedByUser
from app.services.gpt import gpt_service
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(tags=["challenges"])
@@ -23,7 +26,7 @@ router = APIRouter(tags=["challenges"])
async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
result = await db.execute(
select(Challenge)
.options(selectinload(Challenge.game))
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
.where(Challenge.id == challenge_id)
)
challenge = result.scalar_one_or_none()
@@ -32,9 +35,36 @@ async def get_challenge_or_404(db, challenge_id: int) -> 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])
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
result = await db.execute(
select(Game).where(Game.id == game_id)
@@ -54,35 +84,25 @@ 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:
raise HTTPException(status_code=403, detail="Game not accessible")
result = await db.execute(
select(Challenge)
.where(Challenge.game_id == game_id)
.order_by(Challenge.difficulty, Challenge.created_at)
)
# Get challenges with proposed_by
query = select(Challenge).options(selectinload(Challenge.proposed_by)).where(Challenge.game_id == game_id)
# 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()
return [
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
]
return [build_challenge_response(c, game) for c in challenges]
@router.get("/marathons/{marathon_id}/challenges", response_model=list[ChallengeResponse])
async def list_marathon_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
"""List all challenges for a marathon (from all approved games). Participants only."""
"""List all challenges for a marathon (from all approved games). Participants only.
Also includes virtual challenges for playthrough-type games."""
from app.models.game import GameType
# Check marathon exists
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
@@ -94,36 +114,61 @@ async def list_marathon_challenges(marathon_id: int, current_user: CurrentUser,
if not current_user.is_admin and not participant:
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 (challenges type) in this marathon
result = await db.execute(
select(Challenge)
.join(Game, Challenge.game_id == Game.id)
.options(selectinload(Challenge.game))
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
.where(
Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value,
Challenge.status == ChallengeStatus.APPROVED.value,
)
.order_by(Game.title, Challenge.difficulty, Challenge.created_at)
)
challenges = result.scalars().all()
return [
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,
responses = [build_challenge_response(c, c.game) for c in challenges]
# Also get playthrough-type games and create virtual challenges for them
result = await db.execute(
select(Game)
.where(
Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value,
Game.game_type == GameType.PLAYTHROUGH.value,
)
for c in challenges
]
.order_by(Game.title)
)
playthrough_games = result.scalars().all()
for game in playthrough_games:
# Create virtual challenge response for playthrough game
virtual_challenge = ChallengeResponse(
id=-game.id, # Negative ID to distinguish from real challenges
title=f"Прохождение: {game.title}",
description=game.playthrough_description or "Пройдите игру",
type="completion",
difficulty="medium",
points=game.playthrough_points or 0,
estimated_time=None,
proof_type=game.playthrough_proof_type or "screenshot",
proof_hint=game.playthrough_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=False,
created_at=game.created_at,
status="approved",
proposed_by=None,
)
responses.append(virtual_challenge)
return responses
@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse)
@@ -166,25 +211,13 @@ async def create_challenge(
proof_type=data.proof_type.value,
proof_hint=data.proof_hint,
is_generated=False,
status=ChallengeStatus.APPROVED.value, # Organizer-created challenges are auto-approved
)
db.add(challenge)
await db.commit()
await db.refresh(challenge)
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,
)
return build_challenge_response(challenge, game)
@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse)
@@ -386,26 +419,12 @@ async def update_challenge(
await db.commit()
await db.refresh(challenge)
game = 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,
)
return build_challenge_response(challenge, challenge.game)
@router.delete("/challenges/{challenge_id}", response_model=MessageResponse)
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)
# Check marathon is in preparing state
@@ -414,10 +433,206 @@ async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbS
if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Cannot delete challenges from active or finished marathon")
# Only organizers can delete challenges
await require_organizer(db, current_user, challenge.game.marathon_id)
participant = await get_participant(db, current_user.id, 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.commit()
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

@@ -7,7 +7,7 @@ from sqlalchemy.orm import selectinload
from app.api.deps import DbSession, CurrentUser
from app.models import (
Marathon, MarathonStatus, Participant, ParticipantRole,
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge,
Event, EventType, Activity, ActivityType, Assignment, AssignmentStatus, Challenge, Game,
SwapRequest as SwapRequestModel, SwapRequestStatus, User,
)
from fastapi import UploadFile, File, Form
@@ -150,6 +150,46 @@ async def start_event(
detail="Common enemy event requires challenge_id"
)
# Handle playthrough games (negative challenge_id = -game_id)
challenge_id = data.challenge_id
game_id = None
is_playthrough = False
if data.type == EventType.COMMON_ENEMY.value and challenge_id and challenge_id < 0:
# This is a playthrough game, not a real challenge
game_id = -challenge_id # Convert negative to positive game_id
challenge_id = None
is_playthrough = True
# Verify game exists and is a playthrough game
from app.models.game import GameType
result = await db.execute(
select(Game).where(
Game.id == game_id,
Game.marathon_id == marathon_id,
Game.game_type == GameType.PLAYTHROUGH.value,
)
)
game = result.scalar_one_or_none()
if not game:
raise HTTPException(
status_code=400,
detail="Playthrough game not found"
)
elif data.type == EventType.COMMON_ENEMY.value and challenge_id and challenge_id > 0:
# Verify regular challenge exists
result = await db.execute(
select(Challenge)
.options(selectinload(Challenge.game))
.where(Challenge.id == challenge_id)
)
challenge = result.scalar_one_or_none()
if not challenge or challenge.game.marathon_id != marathon_id:
raise HTTPException(
status_code=400,
detail="Challenge not found in this marathon"
)
try:
event = await event_service.start_event(
db=db,
@@ -157,7 +197,9 @@ async def start_event(
event_type=data.type,
created_by_id=current_user.id,
duration_minutes=data.duration_minutes,
challenge_id=data.challenge_id,
challenge_id=challenge_id,
game_id=game_id,
is_playthrough=is_playthrough,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -919,6 +961,41 @@ async def get_common_enemy_leaderboard(
def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
"""Convert Assignment model to AssignmentResponse"""
# Handle playthrough assignments (no challenge, only game)
if assignment.is_playthrough and assignment.game:
game = assignment.game
return AssignmentResponse(
id=assignment.id,
challenge=ChallengeResponse(
id=-game.id, # Negative ID for playthrough
title=f"Прохождение: {game.title}",
description=game.playthrough_description or "Пройдите игру",
type="completion",
difficulty="medium",
points=game.playthrough_points or 0,
estimated_time=None,
proof_type=game.playthrough_proof_type or "screenshot",
proof_hint=game.playthrough_proof_hint,
game=GameShort(
id=game.id,
title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"),
download_url=game.download_url,
game_type=game.game_type,
),
is_generated=False,
created_at=game.created_at,
),
status=assignment.status,
proof_url=storage_service.get_url(assignment.proof_path, "proofs") if assignment.proof_path else assignment.proof_url,
proof_comment=assignment.proof_comment,
points_earned=assignment.points_earned,
streak_at_completion=assignment.streak_at_completion,
started_at=assignment.started_at,
completed_at=assignment.completed_at,
)
# Regular challenge assignment
challenge = assignment.challenge
game = challenge.game
return AssignmentResponse(
@@ -937,6 +1014,7 @@ def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
id=game.id,
title=game.title,
cover_url=storage_service.get_url(game.cover_path, "covers"),
download_url=game.download_url,
),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
@@ -968,7 +1046,8 @@ async def get_event_assignment(
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.challenge).selectinload(Challenge.game)
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough assignments
)
.where(
Assignment.participant_id == participant.id,
@@ -999,10 +1078,19 @@ async def get_event_assignment(
is_completed=False,
)
# Determine challenge_id for response (negative for playthrough)
challenge_id_response = None
if event and event.data:
if event.data.get("is_playthrough"):
game_id = event.data.get("game_id")
challenge_id_response = -game_id if game_id else None
else:
challenge_id_response = event.data.get("challenge_id")
return EventAssignmentResponse(
assignment=assignment_to_response(assignment) if assignment else None,
event_id=event.id if event else None,
challenge_id=event.data.get("challenge_id") if event and event.data else None,
challenge_id=challenge_id_response,
is_completed=is_completed,
)
@@ -1026,6 +1114,7 @@ async def complete_event_assignment(
.options(
selectinload(Assignment.participant),
selectinload(Assignment.challenge).selectinload(Challenge.game),
selectinload(Assignment.game), # For playthrough assignments
)
.where(Assignment.id == assignment_id)
)
@@ -1079,17 +1168,25 @@ async def complete_event_assignment(
assignment.proof_comment = comment
# Get marathon_id
marathon_id = assignment.challenge.game.marathon_id
# Get marathon_id and base points (handle playthrough vs regular challenge)
participant = assignment.participant
if assignment.is_playthrough and assignment.game:
marathon_id = assignment.game.marathon_id
base_points = assignment.game.playthrough_points or 0
challenge_title = f"Прохождение: {assignment.game.title}"
game_title = assignment.game.title
difficulty = "medium"
else:
challenge = assignment.challenge
marathon_id = challenge.game.marathon_id
base_points = challenge.points
challenge_title = challenge.title
game_title = challenge.game.title
difficulty = challenge.difficulty
# Get active event for bonus calculation
active_event = await event_service.get_active_event(db, marathon_id)
# Calculate base points (no streak bonus for event assignments)
participant = assignment.participant
challenge = assignment.challenge
base_points = challenge.points
# Handle common enemy bonus
common_enemy_bonus = 0
common_enemy_closed = False
@@ -1113,12 +1210,13 @@ async def complete_event_assignment(
# Log activity
activity_data = {
"assignment_id": assignment.id,
"game": challenge.game.title,
"challenge": challenge.title,
"difficulty": challenge.difficulty,
"game": game_title,
"challenge": challenge_title,
"difficulty": difficulty,
"points": total_points,
"event_type": EventType.COMMON_ENEMY.value,
"is_event_assignment": True,
"is_playthrough": assignment.is_playthrough,
}
if common_enemy_bonus:
activity_data["common_enemy_bonus"] = common_enemy_bonus

View File

@@ -7,8 +7,12 @@ from app.api.deps import (
require_participant, require_organizer, get_participant,
)
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.assignment import AvailableGamesCount
from app.services.storage import storage_service
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,
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,
)
@@ -145,6 +155,12 @@ async def add_game(
proposed_by_id=current_user.id,
status=game_status,
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)
@@ -171,6 +187,12 @@ async def add_game(
approved_by=UserPublic.model_validate(current_user) if is_organizer else None,
challenges_count=0,
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:
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()
return await get_game(game_id, current_user, db)
@@ -398,3 +432,159 @@ async def upload_cover(
await db.commit()
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
import secrets
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 sqlalchemy import select, func
from sqlalchemy.orm import selectinload
@@ -11,13 +11,16 @@ from app.api.deps import (
require_participant, require_organizer, require_creator,
get_participant,
)
from app.core.config import settings
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 = HTTPBearer(auto_error=False)
from app.models import (
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus,
)
from app.schemas import (
MarathonCreate,
@@ -62,6 +65,7 @@ async def get_marathon_by_code(invite_code: str, db: DbSession):
title=marathon.title,
description=marathon.description,
status=marathon.status,
cover_url=marathon.cover_url,
participants_count=participants_count,
creator_nickname=marathon.creator.nickname,
)
@@ -128,6 +132,7 @@ async def list_marathons(current_user: CurrentUser, db: DbSession):
title=marathon.title,
status=marathon.status,
is_public=marathon.is_public,
cover_url=marathon.cover_url,
participants_count=row[1],
start_date=marathon.start_date,
end_date=marathon.end_date,
@@ -180,6 +185,7 @@ async def create_marathon(
is_public=marathon.is_public,
game_proposal_mode=marathon.game_proposal_mode,
auto_events_enabled=marathon.auto_events_enabled,
cover_url=marathon.cover_url,
start_date=marathon.start_date,
end_date=marathon.end_date,
participants_count=1,
@@ -226,6 +232,7 @@ async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSessio
is_public=marathon.is_public,
game_proposal_mode=marathon.game_proposal_mode,
auto_events_enabled=marathon.auto_events_enabled,
cover_url=marathon.cover_url,
start_date=marathon.start_date,
end_date=marathon.end_date,
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:
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 = []
for game in approved_games:
if game.is_playthrough:
continue # Игры типа "Прохождение" не требуют челленджей
challenge_count = await db.scalar(
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
)
@@ -591,3 +601,366 @@ async def get_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

@@ -73,6 +73,21 @@ class TelegramStatsResponse(BaseModel):
best_streak: int
class TelegramNotificationSettings(BaseModel):
notify_events: bool = True
notify_disputes: bool = True
notify_moderation: bool = True
class Config:
from_attributes = True
class TelegramNotificationSettingsUpdate(BaseModel):
notify_events: bool | None = None
notify_disputes: bool | None = None
notify_moderation: bool | None = None
# Endpoints
@router.post("/generate-link-token", response_model=TelegramLinkToken)
async def generate_link_token(current_user: CurrentUser):
@@ -86,7 +101,7 @@ async def generate_link_token(current_user: CurrentUser):
)
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}"
logger.info(f"[TG_LINK] Bot URL: {bot_url}")
@@ -391,3 +406,46 @@ async def get_user_stats(telegram_id: int, db: DbSession, _: BotSecretDep):
total_points=total_points,
best_streak=best_streak
)
@router.get("/notifications/{telegram_id}", response_model=TelegramNotificationSettings | None)
async def get_notification_settings(telegram_id: int, db: DbSession, _: BotSecretDep):
"""Get user's notification settings by Telegram ID."""
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
return None
return TelegramNotificationSettings.model_validate(user)
@router.patch("/notifications/{telegram_id}", response_model=TelegramNotificationSettings | None)
async def update_notification_settings(
telegram_id: int,
data: TelegramNotificationSettingsUpdate,
db: DbSession,
_: BotSecretDep
):
"""Update user's notification settings by Telegram ID."""
result = await db.execute(
select(User).where(User.telegram_id == telegram_id)
)
user = result.scalar_one_or_none()
if not user:
return None
if data.notify_events is not None:
user.notify_events = data.notify_events
if data.notify_disputes is not None:
user.notify_disputes = data.notify_disputes
if data.notify_moderation is not None:
user.notify_moderation = data.notify_moderation
await db.commit()
await db.refresh(user)
return TelegramNotificationSettings.model_validate(user)

View File

@@ -9,7 +9,8 @@ from app.models.assignment import AssignmentStatus
from app.models.marathon import MarathonStatus
from app.schemas import (
UserPublic, UserPrivate, UserUpdate, TelegramLink, MessageResponse,
PasswordChange, UserStats, UserProfilePublic,
PasswordChange, UserStats, UserProfilePublic, NotificationSettings,
NotificationSettingsUpdate,
)
from app.services.storage import storage_service
@@ -189,6 +190,32 @@ async def change_password(
return MessageResponse(message="Пароль успешно изменен")
@router.get("/me/notifications", response_model=NotificationSettings)
async def get_notification_settings(current_user: CurrentUser):
"""Get current user's notification settings"""
return NotificationSettings.model_validate(current_user)
@router.patch("/me/notifications", response_model=NotificationSettings)
async def update_notification_settings(
data: NotificationSettingsUpdate,
current_user: CurrentUser,
db: DbSession,
):
"""Update current user's notification settings"""
if data.notify_events is not None:
current_user.notify_events = data.notify_events
if data.notify_disputes is not None:
current_user.notify_disputes = data.notify_disputes
if data.notify_moderation is not None:
current_user.notify_moderation = data.notify_moderation
await db.commit()
await db.refresh(current_user)
return NotificationSettings.model_validate(current_user)
@router.get("/me/stats", response_model=UserStats)
async def get_my_stats(current_user: CurrentUser, db: DbSession):
"""Получить свою статистику"""

View File

@@ -9,15 +9,18 @@ from app.core.config import settings
from app.models import (
Marathon, MarathonStatus, Game, Challenge, Participant,
Assignment, AssignmentStatus, Activity, ActivityType,
EventType, Difficulty, User
EventType, Difficulty, User, BonusAssignment, BonusAssignmentStatus, GameType,
DisputeStatus,
)
from app.schemas import (
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.events import event_service
from app.services.storage import storage_service
from app.api.v1.games import get_available_games_for_participant
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(
select(Assignment)
.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(
Assignment.participant_id == participant_id,
@@ -64,7 +69,9 @@ async def get_oldest_returned_assignment(db, participant_id: int) -> Assignment
result = await db.execute(
select(Assignment)
.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(
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)
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
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
marathon = result.scalar_one_or_none()
@@ -115,60 +122,132 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
if active:
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
active_event = await event_service.get_active_event(db, marathon_id)
game = None
challenge = None
is_playthrough = False
# 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.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)
if challenge:
# Load game for challenge
# Check if this game is available for the participant
result = await db.execute(
select(Game).where(Game.id == challenge.game_id)
)
game = result.scalar_one_or_none()
# Consume jackpot (one-time use)
await event_service.consume_jackpot(db, active_event.id)
if game and game.id in [g.id for g in available_games]:
# Consume jackpot (one-time use)
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
# Normal random selection if no special event handling
if not game or not challenge:
result = await db.execute(
select(Game)
.options(selectinload(Game.challenges))
.where(Game.marathon_id == marathon_id)
if not game:
game = random.choice(available_games)
if game.game_type == GameType.PLAYTHROUGH.value:
# Playthrough game - no challenge selection
# Events that apply to playthrough: GOLDEN_HOUR, DOUBLE_RISK, COMMON_ENEMY
# Events that DON'T apply: JACKPOT (hard challenges only)
is_playthrough = True
challenge = None
if active_event and active_event.type == EventType.JACKPOT.value:
active_event = None # Jackpot doesn't apply to playthrough
else:
# Challenges game - select random challenge
if not game.challenges:
# Reload challenges if not loaded
result = await db.execute(
select(Game)
.options(selectinload(Game.challenges))
.where(Game.id == game.id)
)
game = result.scalar_one()
# Filter out already completed challenges
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]
if not available_challenges:
raise HTTPException(status_code=400, detail="No challenges available for this game")
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,
event_type=active_event.type if active_event else None,
)
games = [g for g in result.scalars().all() if g.challenges]
db.add(assignment)
await db.flush() # Get assignment.id for bonus assignments
if not games:
raise HTTPException(status_code=400, detail="No games with challenges available")
# 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)
game = random.choice(games)
challenge = random.choice(game.challenges)
# Log activity
activity_data = {
"game": game.title,
"is_playthrough": True,
"points": game.playthrough_points,
"bonus_challenges_count": len(bonus_challenges),
}
if active_event:
activity_data["event_type"] = active_event.type
else:
# Regular challenge assignment
assignment = Assignment(
participant_id=participant.id,
challenge_id=challenge.id,
status=AssignmentStatus.ACTIVE.value,
event_type=active_event.type if active_event else None,
)
db.add(assignment)
# Create assignment (store event_type for jackpot multiplier on completion)
assignment = Assignment(
participant_id=participant.id,
challenge_id=challenge.id,
status=AssignmentStatus.ACTIVE.value,
event_type=active_event.type if active_event else None,
)
db.add(assignment)
# Log activity
activity_data = {
"game": game.title,
"challenge": challenge.title,
"difficulty": challenge.difficulty,
"points": challenge.points,
}
if active_event:
activity_data["event_type"] = active_event.type
# Log activity
activity_data = {
"game": game.title,
"challenge": challenge.title,
"difficulty": challenge.difficulty,
"points": challenge.points,
}
if active_event:
activity_data["event_type"] = active_event.type
activity = Activity(
marathon_id=marathon_id,
@@ -181,10 +260,17 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
await db.commit()
await db.refresh(assignment)
# Calculate drop penalty (considers active event for double_risk)
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, challenge.points, active_event)
# Calculate drop penalty
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
if 'challenges' in game.__dict__:
challenges_count = len(game.challenges)
@@ -193,36 +279,82 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
)
return SpinResult(
assignment_id=assignment.id,
game=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,
added_by=None,
challenges_count=challenges_count,
created_at=game.created_at,
),
challenge=ChallengeResponse(
id=challenge.id,
title=challenge.title,
description=challenge.description,
type=challenge.type,
difficulty=challenge.difficulty,
points=challenge.points,
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
),
can_drop=True,
drop_penalty=drop_penalty,
# Build response
game_response = 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,
added_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,
)
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,
event_type=active_event.type if active_event else None,
)
else:
# Return challenge result
return SpinResult(
assignment_id=assignment.id,
game=game_response,
challenge=ChallengeResponse(
id=challenge.id,
title=challenge.title,
description=challenge.description,
type=challenge.type,
difficulty=challenge.difficulty,
points=challenge.points,
estimated_time=challenge.estimated_time,
proof_type=challenge.proof_type,
proof_hint=challenge.proof_hint,
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
is_generated=challenge.is_generated,
created_at=challenge.created_at,
),
is_playthrough=False,
can_drop=True,
drop_penalty=drop_penalty,
event_type=active_event.type if active_event else None,
)
@router.get("/marathons/{marathon_id}/current-assignment", response_model=AssignmentResponse | None)
async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db: DbSession):
@@ -230,9 +362,86 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
participant = await get_participant_or_403(db, current_user.id, marathon_id)
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:
return None
# Handle playthrough assignments
if assignment.is_playthrough:
game = assignment.game
# Use stored event_type for playthrough
# All events except JACKPOT apply (DOUBLE_RISK = free drop, others affect points)
playthrough_event = None
if assignment.event_type and assignment.event_type != EventType.JACKPOT.value:
class MockEvent:
def __init__(self, event_type):
self.type = event_type
playthrough_event = MockEvent(assignment.event_type)
drop_penalty = points_service.calculate_drop_penalty(
participant.drop_count, game.playthrough_points, playthrough_event
)
# 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,
event_type=assignment.event_type,
)
# Regular challenge assignment
challenge = assignment.challenge
game = challenge.game
@@ -252,7 +461,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
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),
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,
created_at=challenge.created_at,
),
@@ -264,6 +473,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
started_at=assignment.started_at,
completed_at=assignment.completed_at,
drop_penalty=drop_penalty,
event_type=assignment.event_type,
)
@@ -274,15 +484,19 @@ async def complete_assignment(
db: DbSession,
proof_url: 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)"""
# Get assignment
# Get assignment with all needed relationships
result = await db.execute(
select(Assignment)
.options(
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)
)
@@ -301,62 +515,178 @@ async def complete_assignment(
if assignment.is_event_assignment:
raise HTTPException(status_code=400, detail="Use /event-assignments/{id}/complete for event assignments")
# Need either file or URL
if not proof_file and not proof_url:
raise HTTPException(status_code=400, detail="Proof is required (file or URL)")
# Handle file upload
# Combine legacy single file with new multiple files
all_files = []
if proof_file:
contents = await proof_file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=400,
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
)
all_files.append(proof_file)
if proof_files:
all_files.extend(proof_files)
ext = proof_file.filename.split(".")[-1].lower() if proof_file.filename else "jpg"
if ext not in settings.ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}",
)
# Upload file to storage
filename = storage_service.generate_filename(assignment_id, proof_file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="proofs",
filename=filename,
content_type=proof_file.content_type or "application/octet-stream",
)
assignment.proof_path = file_path
# 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)")
# Handle multiple file uploads
if all_files:
from app.models import AssignmentProof
for idx, file in enumerate(all_files):
contents = await file.read()
if len(contents) > settings.MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=400,
detail=f"File {file.filename} too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
)
ext = file.filename.split(".")[-1].lower() if file.filename else "jpg"
if ext not in settings.ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
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
filename = storage_service.generate_filename(f"{assignment_id}_{idx}", file.filename)
file_path = await storage_service.upload_file(
content=contents,
folder="proofs",
filename=filename,
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
# Set proof URL if provided
if proof_url:
assignment.proof_url = proof_url
assignment.proof_comment = comment
# Calculate points
participant = assignment.participant
challenge = assignment.challenge
# Get marathon_id for activity and event check
result = await db.execute(
select(Challenge).options(selectinload(Challenge.game)).where(Challenge.id == challenge.id)
)
full_challenge = result.scalar_one()
marathon_id = full_challenge.game.marathon_id
# Handle playthrough completion
if assignment.is_playthrough:
game = assignment.game
marathon_id = game.marathon_id
base_playthrough_points = game.playthrough_points
# Calculate BASE bonus points from completed bonus assignments (before multiplier)
base_bonus_points = sum(
ba.challenge.points for ba in assignment.bonus_assignments
if ba.status == BonusAssignmentStatus.COMPLETED.value
)
# Total base = playthrough + all bonuses
total_base_points = base_playthrough_points + base_bonus_points
# Get event for playthrough (use stored event_type from assignment)
# All events except JACKPOT apply to playthrough
playthrough_event = None
if assignment.event_type and assignment.event_type != EventType.JACKPOT.value:
class MockEvent:
def __init__(self, event_type):
self.type = event_type
playthrough_event = MockEvent(assignment.event_type)
# Apply multiplier to the TOTAL (base + bonuses), then add streak bonus
total_points, streak_bonus, event_bonus = points_service.calculate_completion_points(
total_base_points, participant.current_streak, playthrough_event
)
# Update bonus assignments to reflect multiplied points for display
if playthrough_event:
multiplier = points_service.EVENT_MULTIPLIERS.get(playthrough_event.type, 1.0)
for ba in assignment.bonus_assignments:
if ba.status == BonusAssignmentStatus.COMPLETED.value:
ba.points_earned = int(ba.challenge.points * multiplier)
# 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_playthrough_points,
"bonus_points": base_bonus_points,
"streak": participant.current_streak,
}
if is_redo:
activity_data["is_redo"] = True
if playthrough_event:
activity_data["event_type"] = playthrough_event.type
activity_data["event_bonus"] = event_bonus
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
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 other events: use the currently active event
effective_event = active_event
# Handle assignment-level event types (jackpot)
if assignment.event_type == EventType.JACKPOT.value:
# Create a mock event object for point calculation
class MockEvent:
def __init__(self, event_type):
self.type = event_type
@@ -386,18 +716,25 @@ async def complete_assignment(
# Update participant
participant.total_points += total_points
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
activity_data = {
"assignment_id": assignment.id,
"game": full_challenge.game.title,
"game": challenge.game.title,
"challenge": challenge.title,
"difficulty": challenge.difficulty,
"points": total_points,
"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:
activity_data["event_type"] = assignment.event_type
activity_data["event_bonus"] = event_bonus
@@ -418,7 +755,6 @@ async def complete_assignment(
# If common enemy event auto-closed, log the event end with winners
if common_enemy_closed and common_enemy_winners:
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
# Load winner nicknames
winner_user_ids = [w["user_id"] for w in common_enemy_winners]
users_result = await db.execute(
select(User).where(User.id.in_(winner_user_ids))
@@ -438,7 +774,7 @@ async def complete_assignment(
event_end_activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id, # Last completer triggers the close
user_id=current_user.id,
type=ActivityType.EVENT_END.value,
data={
"event_type": EventType.COMMON_ENEMY.value,
@@ -451,7 +787,7 @@ async def complete_assignment(
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)
if returned_assignment:
await activate_returned_assignment(db, returned_assignment)
@@ -469,12 +805,14 @@ async def complete_assignment(
@router.post("/assignments/{assignment_id}/drop", response_model=DropResult)
async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession):
"""Drop current assignment"""
# Get assignment
# Get assignment with all needed relationships
result = await db.execute(
select(Assignment)
.options(
selectinload(Assignment.participant),
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)
)
@@ -490,6 +828,74 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
raise HTTPException(status_code=400, detail="Assignment is not active")
participant = assignment.participant
# Handle playthrough drop
if assignment.is_playthrough:
game = assignment.game
marathon_id = game.marathon_id
# Use stored event_type for drop penalty calculation
# DOUBLE_RISK = free drop (0 penalty)
playthrough_event = None
if assignment.event_type and assignment.event_type != EventType.JACKPOT.value:
class MockEvent:
def __init__(self, event_type):
self.type = event_type
playthrough_event = MockEvent(assignment.event_type)
penalty = points_service.calculate_drop_penalty(
participant.drop_count, game.playthrough_points, playthrough_event
)
# 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_data = {
"game": game.title,
"is_playthrough": True,
"penalty": penalty,
"lost_bonuses": completed_bonuses_count,
}
if playthrough_event:
activity_data["event_type"] = playthrough_event.type
activity_data["free_drop"] = True
activity = Activity(
marathon_id=marathon_id,
user_id=current_user.id,
type=ActivityType.DROP.value,
data=activity_data,
)
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
# Check active event for free drops (double_risk)
@@ -550,7 +956,9 @@ async def get_my_history(
result = await db.execute(
select(Assignment)
.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)
.order_by(Assignment.started_at.desc())
@@ -559,34 +967,89 @@ async def get_my_history(
)
assignments = result.scalars().all()
return [
AssignmentResponse(
id=a.id,
challenge=ChallengeResponse(
id=a.challenge.id,
title=a.challenge.title,
description=a.challenge.description,
type=a.challenge.type,
difficulty=a.challenge.difficulty,
points=a.challenge.points,
estimated_time=a.challenge.estimated_time,
proof_type=a.challenge.proof_type,
proof_hint=a.challenge.proof_hint,
game=GameShort(
id=a.challenge.game.id,
title=a.challenge.game.title,
cover_url=None
responses = []
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,
),
is_generated=a.challenge.is_generated,
created_at=a.challenge.created_at,
),
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,
)
for a in assignments
]
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,
challenge=ChallengeResponse(
id=a.challenge.id,
title=a.challenge.title,
description=a.challenge.description,
type=a.challenge.type,
difficulty=a.challenge.difficulty,
points=a.challenge.points,
estimated_time=a.challenge.estimated_time,
proof_type=a.challenge.proof_type,
proof_hint=a.challenge.proof_hint,
game=GameShort(
id=a.challenge.game.id,
title=a.challenge.game.title,
cover_url=None,
download_url=a.challenge.game.download_url,
game_type=a.challenge.game.game_type,
),
is_generated=a.challenge.is_generated,
created_at=a.challenge.created_at,
),
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,
))
return responses

View File

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

View File

@@ -1,5 +1,10 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
from app.core.config import settings
# 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.marathon import Marathon, MarathonStatus, GameProposalMode
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.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.event import Event, EventType
from app.models.swap_request import SwapRequest, SwapRequestStatus
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__ = [
"User",
@@ -19,12 +24,17 @@ __all__ = [
"ParticipantRole",
"Game",
"GameStatus",
"GameType",
"Challenge",
"ChallengeType",
"Difficulty",
"ProofType",
"Assignment",
"AssignmentStatus",
"BonusAssignment",
"BonusAssignmentStatus",
"AssignmentProof",
"BonusAssignmentProof",
"Activity",
"ActivityType",
"Event",
@@ -35,4 +45,8 @@ __all__ = [
"DisputeStatus",
"DisputeComment",
"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)
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)
# Для прохождений (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
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
@@ -33,6 +37,9 @@ class Assignment(Base):
# Relationships
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")
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"
class ChallengeStatus(str, Enum):
PENDING = "pending"
APPROVED = "approved"
REJECTED = "rejected"
class Challenge(Base):
__tablename__ = "challenges"
@@ -45,8 +51,13 @@ class Challenge(Base):
is_generated: Mapped[bool] = mapped_column(Boolean, default=True)
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
game: Mapped["Game"] = relationship("Game", back_populates="challenges")
proposed_by: Mapped["User"] = relationship("User", foreign_keys=[proposed_by_id])
assignments: Mapped[list["Assignment"]] = relationship(
"Assignment",
back_populates="challenge"

View File

@@ -8,16 +8,19 @@ from app.core.database import Base
class DisputeStatus(str, Enum):
OPEN = "open"
PENDING_ADMIN = "pending_admin" # Voting ended, waiting for admin decision
RESOLVED_VALID = "valid"
RESOLVED_INVALID = "invalid"
class Dispute(Base):
"""Dispute against a completed assignment's proof"""
"""Dispute against a completed assignment's or bonus assignment's proof"""
__tablename__ = "disputes"
id: Mapped[int] = mapped_column(primary_key=True)
assignment_id: Mapped[int] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), unique=True, index=True)
# 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"))
reason: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[str] = mapped_column(String(20), default=DisputeStatus.OPEN.value)
@@ -26,6 +29,7 @@ class Dispute(Base):
# Relationships
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])
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")

View File

@@ -1,6 +1,6 @@
from datetime import datetime
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 app.core.database import Base
@@ -12,6 +12,11 @@ class GameStatus(str, Enum):
REJECTED = "rejected" # Отклонена
class GameType(str, Enum):
PLAYTHROUGH = "playthrough" # Прохождение игры
CHALLENGES = "challenges" # Челленджи
class Game(Base):
__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)
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
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="games")
proposed_by: Mapped["User"] = relationship(
@@ -43,6 +57,12 @@ class Game(Base):
back_populates="game",
cascade="all, delete-orphan"
)
# Assignments для прохождений (playthrough)
playthrough_assignments: Mapped[list["Assignment"]] = relationship(
"Assignment",
back_populates="game",
foreign_keys="Assignment.game_id"
)
@property
def is_approved(self) -> bool:
@@ -51,3 +71,11 @@ class Game(Base):
@property
def is_pending(self) -> bool:
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)
end_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=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)
# 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 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 app.core.database import Base
@@ -27,6 +27,18 @@ class User(Base):
role: Mapped[str] = mapped_column(String(20), default=UserRole.USER.value)
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)
# Notification settings (all enabled by default)
notify_events: Mapped[bool] = mapped_column(Boolean, default=True)
notify_disputes: Mapped[bool] = mapped_column(Boolean, default=True)
notify_moderation: Mapped[bool] = mapped_column(Boolean, default=True)
# Relationships
created_marathons: Mapped[list["Marathon"]] = relationship(
"Marathon",
@@ -47,6 +59,11 @@ class User(Base):
back_populates="approved_by",
foreign_keys="Game.approved_by_id"
)
banned_by: Mapped["User | None"] = relationship(
"User",
remote_side="User.id",
foreign_keys=[banned_by_id]
)
@property
def is_admin(self) -> bool:

View File

@@ -9,6 +9,8 @@ from app.schemas.user import (
PasswordChange,
UserStats,
UserProfilePublic,
NotificationSettings,
NotificationSettingsUpdate,
)
from app.schemas.marathon import (
MarathonCreate,
@@ -46,6 +48,10 @@ from app.schemas.assignment import (
CompleteResult,
DropResult,
EventAssignmentResponse,
BonusAssignmentResponse,
CompleteBonusAssignment,
BonusCompleteResult,
AvailableGamesCount,
)
from app.schemas.activity import (
ActivityResponse,
@@ -81,6 +87,23 @@ from app.schemas.dispute import (
AssignmentDetailResponse,
ReturnedAssignmentResponse,
)
from app.schemas.admin import (
BanUserRequest,
AdminResetPasswordRequest,
AdminUserResponse,
AdminLogResponse,
AdminLogsListResponse,
BroadcastRequest,
BroadcastResponse,
StaticContentResponse,
StaticContentUpdate,
StaticContentCreate,
TwoFactorInitiateRequest,
TwoFactorInitiateResponse,
TwoFactorVerifyRequest,
LoginResponse,
DashboardStats,
)
__all__ = [
# User
@@ -94,6 +117,8 @@ __all__ = [
"PasswordChange",
"UserStats",
"UserProfilePublic",
"NotificationSettings",
"NotificationSettingsUpdate",
# Marathon
"MarathonCreate",
"MarathonUpdate",
@@ -127,6 +152,10 @@ __all__ = [
"CompleteResult",
"DropResult",
"EventAssignmentResponse",
"BonusAssignmentResponse",
"CompleteBonusAssignment",
"BonusCompleteResult",
"AvailableGamesCount",
# Activity
"ActivityResponse",
"FeedResponse",
@@ -157,4 +186,20 @@ __all__ = [
"DisputeResponse",
"AssignmentDetailResponse",
"ReturnedAssignmentResponse",
# Admin
"BanUserRequest",
"AdminResetPasswordRequest",
"AdminUserResponse",
"AdminLogResponse",
"AdminLogsListResponse",
"BroadcastRequest",
"BroadcastResponse",
"StaticContentResponse",
"StaticContentUpdate",
"StaticContentCreate",
"TwoFactorInitiateRequest",
"TwoFactorInitiateResponse",
"TwoFactorVerifyRequest",
"LoginResponse",
"DashboardStats",
]

View File

@@ -0,0 +1,127 @@
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
# Notification settings
notify_events: bool = True
notify_disputes: bool = True
notify_moderation: bool = True
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 pydantic import BaseModel
from app.schemas.game import GameResponse
from app.schemas.game import GameResponse, GameShort, PlaythroughInfo
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):
pass
@@ -14,9 +25,28 @@ class CompleteAssignment(BaseModel):
comment: str | None = None
class AssignmentResponse(BaseModel):
class BonusAssignmentResponse(BaseModel):
"""Ответ с информацией о бонусном челлендже"""
id: int
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
proof_url: str | None = None
proof_comment: str | None = None
@@ -25,6 +55,8 @@ class AssignmentResponse(BaseModel):
started_at: datetime
completed_at: datetime | None = None
drop_penalty: int = 0 # Calculated penalty if dropped
bonus_challenges: list[BonusAssignmentResponse] = [] # Для playthrough
event_type: str | None = None # Event type if assignment was created during event
class Config:
from_attributes = True
@@ -33,9 +65,13 @@ class AssignmentResponse(BaseModel):
class SpinResult(BaseModel):
assignment_id: int
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
drop_penalty: int
event_type: str | None = None # Event type if active during spin
class CompleteResult(BaseModel):
@@ -60,3 +96,22 @@ class EventAssignmentResponse(BaseModel):
class Config:
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 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
class ProposedByUser(BaseModel):
"""Minimal user info for proposed challenges"""
id: int
nickname: str
class Config:
from_attributes = True
class ChallengeBase(BaseModel):
title: str = Field(..., min_length=1, max_length=100)
description: str = Field(..., min_length=1)
type: ChallengeType
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
proof_type: ProofType
proof_hint: str | None = None
@@ -25,7 +34,7 @@ class ChallengeUpdate(BaseModel):
description: str | None = None
type: ChallengeType | 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
proof_type: ProofType | None = None
proof_hint: str | None = None
@@ -36,11 +45,18 @@ class ChallengeResponse(ChallengeBase):
game: GameShort
is_generated: bool
created_at: datetime
status: str = "approved"
proposed_by: ProposedByUser | None = None
class Config:
from_attributes = True
class ChallengePropose(ChallengeBase):
"""Schema for proposing a challenge by a participant"""
pass
class ChallengeGenerated(BaseModel):
"""Schema for GPT-generated challenges"""
title: str

View File

@@ -1,8 +1,14 @@
from datetime import datetime
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field
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):
@@ -63,11 +69,15 @@ class DisputeResponse(BaseModel):
class AssignmentDetailResponse(BaseModel):
"""Detailed assignment information with proofs and dispute"""
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
status: str
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
points_earned: int
streak_at_completion: int | None
@@ -75,6 +85,7 @@ class AssignmentDetailResponse(BaseModel):
completed_at: datetime | None
can_dispute: bool # True if <24h since completion and not own assignment
dispute: DisputeResponse | None
bonus_challenges: list[dict] | None = None # For playthrough
class Config:
from_attributes = True
@@ -83,7 +94,11 @@ class AssignmentDetailResponse(BaseModel):
class ReturnedAssignmentResponse(BaseModel):
"""Returned assignment that needs to be redone"""
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
dispute_reason: str

View File

@@ -1,6 +1,9 @@
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
@@ -13,17 +16,48 @@ class GameBase(BaseModel):
class GameCreate(GameBase):
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):
title: str | None = Field(None, min_length=1, max_length=100)
download_url: 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):
id: int
title: str
cover_url: str | None = None
download_url: str
game_type: str = "challenges"
class Config:
from_attributes = True
@@ -38,5 +72,22 @@ class GameResponse(GameBase):
challenges_count: int = 0
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:
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
game_proposal_mode: str
auto_events_enabled: bool
cover_url: str | None
start_date: datetime | None
end_date: datetime | None
participants_count: int
@@ -69,6 +70,7 @@ class MarathonListItem(BaseModel):
title: str
status: str
is_public: bool
cover_url: str | None
participants_count: int
start_date: datetime | None
end_date: datetime | None
@@ -87,6 +89,7 @@ class MarathonPublicInfo(BaseModel):
title: str
description: str | None
status: str
cover_url: str | None
participants_count: int
creator_nickname: str

View File

@@ -47,6 +47,10 @@ class UserPrivate(UserPublic):
telegram_username: str | None = None
telegram_first_name: str | None = None
telegram_last_name: str | None = None
# Notification settings
notify_events: bool = True
notify_disputes: bool = True
notify_moderation: bool = True
class TokenResponse(BaseModel):
@@ -83,3 +87,20 @@ class UserProfilePublic(BaseModel):
class Config:
from_attributes = True
class NotificationSettings(BaseModel):
"""Notification settings for Telegram bot"""
notify_events: bool = True
notify_disputes: bool = True
notify_moderation: bool = True
class Config:
from_attributes = True
class NotificationSettingsUpdate(BaseModel):
"""Update notification settings"""
notify_events: bool | None = None
notify_disputes: bool | None = None
notify_moderation: bool | None = None

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
from datetime import datetime, timedelta
@@ -8,16 +8,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import Dispute, DisputeStatus, Assignment, AssignmentStatus
from app.services.disputes import dispute_service
from app.services.telegram_notifier import telegram_notifier
# Configuration
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:
"""Background scheduler for automatic dispute resolution."""
"""Background scheduler that marks expired disputes for admin review."""
def __init__(self):
self._running = False
@@ -55,7 +55,7 @@ class DisputeScheduler:
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
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)
# Find all open disputes that have expired
@@ -63,7 +63,6 @@ class DisputeScheduler:
select(Dispute)
.options(
selectinload(Dispute.votes),
selectinload(Dispute.assignment).selectinload(Assignment.participant),
)
.where(
Dispute.status == DisputeStatus.OPEN.value,
@@ -74,15 +73,25 @@ class DisputeScheduler:
for dispute in expired_disputes:
try:
result_status, votes_valid, votes_invalid = await dispute_service.resolve_dispute(
db, dispute.id
)
# Count votes for logging
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(
f"[DisputeScheduler] Auto-resolved dispute {dispute.id}: "
f"{result_status} (valid: {votes_valid}, invalid: {votes_invalid})"
f"[DisputeScheduler] Dispute {dispute.id} marked as pending admin "
f"(recommendation: {'invalid' if votes_invalid > votes_valid else 'valid'}, "
f"votes: {votes_valid} valid, {votes_invalid} invalid)"
)
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

View File

@@ -23,12 +23,15 @@ class DisputeService:
Returns:
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(
select(Dispute)
.options(
selectinload(Dispute.votes),
selectinload(Dispute.assignment).selectinload(Assignment.participant),
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant),
)
.where(Dispute.id == dispute_id)
)
@@ -46,9 +49,12 @@ class DisputeService:
# Determine result: tie goes to the accused (valid)
if votes_invalid > votes_valid:
# Proof is invalid - mark assignment as RETURNED
# Proof is invalid
result_status = DisputeStatus.RESOLVED_INVALID.value
await self._handle_invalid_proof(db, dispute)
if dispute.bonus_assignment_id:
await self._handle_invalid_bonus_proof(db, dispute)
else:
await self._handle_invalid_proof(db, dispute)
else:
# Proof is valid (or tie)
result_status = DisputeStatus.RESOLVED_VALID.value
@@ -60,7 +66,11 @@ class DisputeService:
await db.commit()
# 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
@@ -72,12 +82,13 @@ class DisputeService:
) -> None:
"""Send notification about dispute resolution to the assignment owner."""
try:
# Get assignment with challenge and marathon info
# Get assignment with challenge/game and marathon info
result = await db.execute(
select(Assignment)
.options(
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)
)
@@ -86,12 +97,19 @@ class DisputeService:
return
participant = assignment.participant
challenge = assignment.challenge
game = challenge.game if challenge else None
# 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
title = challenge.title if challenge else "Unknown"
marathon_id = challenge.game.marathon_id if challenge and challenge.game else 0
# Get marathon
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()
@@ -100,12 +118,86 @@ class DisputeService:
db,
user_id=participant.user_id,
marathon_title=marathon.title,
challenge_title=challenge.title if challenge else "Unknown",
challenge_title=title,
is_valid=is_valid
)
except Exception as 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:
"""
Handle the case when proof is determined to be invalid.
@@ -113,7 +205,10 @@ class DisputeService:
- Mark assignment as RETURNED
- Subtract points from participant
- Reset streak if it was affected
- For playthrough: also reset bonus assignments
"""
from app.models import BonusAssignment, BonusAssignmentStatus
assignment = dispute.assignment
participant = assignment.participant
@@ -121,22 +216,45 @@ class DisputeService:
points_to_subtract = assignment.points_earned
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
assignment.status = AssignmentStatus.RETURNED.value
assignment.points_earned = 0
# 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, "
f"subtracted {points_to_subtract} points from participant {participant.id}")
async def get_pending_disputes(self, db: AsyncSession, older_than_hours: int = 24) -> list[Dispute]:
"""Get all open disputes older than specified hours"""
"""Get all open disputes (both regular and bonus) older than specified hours"""
from datetime import timedelta
from app.models import BonusAssignment
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
result = await db.execute(
select(Dispute)
.options(
selectinload(Dispute.assignment),
selectinload(Dispute.bonus_assignment),
)
.where(
Dispute.status == DisputeStatus.OPEN.value,
Dispute.created_at < cutoff_time,

View File

@@ -47,6 +47,8 @@ class EventService:
created_by_id: int | None = None,
duration_minutes: int | None = None,
challenge_id: int | None = None,
game_id: int | None = None,
is_playthrough: bool = False,
) -> Event:
"""Start a new event"""
# Check no active event
@@ -63,8 +65,12 @@ class EventService:
# Build event data
data = {}
if event_type == EventType.COMMON_ENEMY.value and challenge_id:
data["challenge_id"] = challenge_id
if event_type == EventType.COMMON_ENEMY.value and (challenge_id or game_id):
if is_playthrough and game_id:
data["game_id"] = game_id
data["is_playthrough"] = True
else:
data["challenge_id"] = challenge_id
data["completions"] = [] # Track who completed and when
event = Event(
@@ -79,9 +85,11 @@ class EventService:
db.add(event)
await db.flush() # Get event.id before committing
# Auto-assign challenge to all participants for Common Enemy
if event_type == EventType.COMMON_ENEMY.value and challenge_id:
await self._assign_common_enemy_to_all(db, marathon_id, event.id, challenge_id)
# Auto-assign challenge/playthrough to all participants for Common Enemy
if event_type == EventType.COMMON_ENEMY.value and (challenge_id or game_id):
await self._assign_common_enemy_to_all(
db, marathon_id, event.id, challenge_id, game_id, is_playthrough
)
await db.commit()
await db.refresh(event)
@@ -105,7 +113,9 @@ class EventService:
db: AsyncSession,
marathon_id: int,
event_id: int,
challenge_id: int,
challenge_id: int | None,
game_id: int | None = None,
is_playthrough: bool = False,
) -> None:
"""Create event assignments for all participants in the marathon"""
# Get all participants
@@ -118,7 +128,9 @@ class EventService:
for participant in participants:
assignment = Assignment(
participant_id=participant.id,
challenge_id=challenge_id,
challenge_id=challenge_id if not is_playthrough else None,
game_id=game_id if is_playthrough else None,
is_playthrough=is_playthrough,
status=AssignmentStatus.ACTIVE.value,
event_type=EventType.COMMON_ENEMY.value,
is_event_assignment=True,
@@ -290,6 +302,30 @@ class EventService:
)
return result.scalar_one_or_none()
async def get_common_enemy_game(
self,
db: AsyncSession,
event: Event
):
"""Get the playthrough game for common enemy event (if it's a playthrough)"""
from app.models import Game
if event.type != EventType.COMMON_ENEMY.value:
return None
data = event.data or {}
if not data.get("is_playthrough"):
return None
game_id = data.get("game_id")
if not game_id:
return None
result = await db.execute(
select(Game).where(Game.id == game_id)
)
return result.scalar_one_or_none()
def get_time_remaining(self, event: Event | None) -> int | None:
"""Get remaining time in seconds for an event"""
if not event or not event.end_time:

View File

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

View File

@@ -54,6 +54,209 @@ class TelegramNotifier:
logger.error(f"Error sending Telegram message: {e}")
return False
async def send_photo(
self,
chat_id: int,
photo: bytes,
caption: str | None = None,
parse_mode: str = "HTML",
filename: str = "photo.jpg",
content_type: str = "image/jpeg"
) -> bool:
"""Send a photo to a Telegram chat."""
if not self.bot_token:
logger.warning("Telegram bot token not configured")
return False
try:
timeout = httpx.Timeout(connect=30.0, read=60.0, write=120.0, pool=30.0)
async with httpx.AsyncClient(timeout=timeout) as client:
data = {"chat_id": str(chat_id)}
if caption:
data["caption"] = caption
data["parse_mode"] = parse_mode
files = {"photo": (filename, photo, content_type)}
response = await client.post(
f"{self.api_url}/sendPhoto",
data=data,
files=files,
)
if response.status_code == 200:
return True
else:
logger.error(f"Failed to send photo to {chat_id}: {response.status_code} - {response.text}")
return False
except Exception as e:
logger.error(f"Error sending Telegram photo to {chat_id}: {type(e).__name__}: {e}")
return False
async def send_video(
self,
chat_id: int,
video: bytes,
caption: str | None = None,
parse_mode: str = "HTML",
filename: str = "video.mp4",
content_type: str = "video/mp4"
) -> bool:
"""Send a video to a Telegram chat."""
if not self.bot_token:
logger.warning("Telegram bot token not configured")
return False
try:
timeout = httpx.Timeout(connect=30.0, read=120.0, write=300.0, pool=30.0)
async with httpx.AsyncClient(timeout=timeout) as client:
data = {"chat_id": str(chat_id)}
if caption:
data["caption"] = caption
data["parse_mode"] = parse_mode
files = {"video": (filename, video, content_type)}
response = await client.post(
f"{self.api_url}/sendVideo",
data=data,
files=files,
)
if response.status_code == 200:
return True
else:
logger.error(f"Failed to send video to {chat_id}: {response.status_code} - {response.text}")
return False
except Exception as e:
logger.error(f"Error sending Telegram video to {chat_id}: {type(e).__name__}: {e}")
return False
async def send_media_group(
self,
chat_id: int,
media_items: list[dict],
caption: str | None = None,
parse_mode: str = "HTML"
) -> bool:
"""
Send a media group (multiple photos/videos) to a Telegram chat.
media_items: list of dicts with keys:
- type: "photo" or "video"
- data: bytes
- filename: str
- content_type: str
"""
if not self.bot_token:
logger.warning("Telegram bot token not configured")
return False
if not media_items:
return False
try:
import json
# Use longer timeouts for file uploads
timeout = httpx.Timeout(
connect=30.0,
read=120.0,
write=300.0, # 5 minutes for uploading files
pool=30.0
)
async with httpx.AsyncClient(timeout=timeout) as client:
# Build media array and files dict
media_array = []
files_dict = {}
for i, item in enumerate(media_items):
attach_name = f"media{i}"
media_obj = {
"type": item["type"],
"media": f"attach://{attach_name}"
}
# Only first item gets the caption
if i == 0 and caption:
media_obj["caption"] = caption
media_obj["parse_mode"] = parse_mode
media_array.append(media_obj)
files_dict[attach_name] = (
item.get("filename", f"file{i}"),
item["data"],
item.get("content_type", "application/octet-stream")
)
data = {
"chat_id": str(chat_id),
"media": json.dumps(media_array)
}
logger.info(f"Sending media group to {chat_id}: {len(media_items)} files")
response = await client.post(
f"{self.api_url}/sendMediaGroup",
data=data,
files=files_dict,
)
if response.status_code == 200:
logger.info(f"Successfully sent media group to {chat_id}")
return True
else:
logger.error(f"Failed to send media group to {chat_id}: {response.status_code} - {response.text}")
return False
except Exception as e:
logger.error(f"Error sending Telegram media group to {chat_id}: {type(e).__name__}: {e}")
return False
async def send_media_message(
self,
chat_id: int,
text: str | None = None,
media_type: str | None = None,
media_data: bytes | None = None,
media_items: list[dict] | None = None,
parse_mode: str = "HTML"
) -> bool:
"""
Send a message with optional media.
For single media: use media_type and media_data
For multiple media: use media_items list with dicts containing:
- type: "photo" or "video"
- data: bytes
- filename: str (optional)
- content_type: str (optional)
"""
# Multiple media - use media group
if media_items and len(media_items) > 1:
return await self.send_media_group(chat_id, media_items, text, parse_mode)
# Single media from media_items
if media_items and len(media_items) == 1:
item = media_items[0]
if item["type"] == "photo":
return await self.send_photo(
chat_id, item["data"], text, parse_mode,
item.get("filename", "photo.jpg"),
item.get("content_type", "image/jpeg")
)
elif item["type"] == "video":
return await self.send_video(
chat_id, item["data"], text, parse_mode,
item.get("filename", "video.mp4"),
item.get("content_type", "video/mp4")
)
# Legacy single media support
if media_data and media_type:
if media_type == "photo":
return await self.send_photo(chat_id, media_data, text, parse_mode)
elif media_type == "video":
return await self.send_video(chat_id, media_data, text, parse_mode)
if text:
return await self.send_message(chat_id, text, parse_mode)
return False
async def notify_user(
self,
db: AsyncSession,
@@ -83,9 +286,15 @@ class TelegramNotifier:
db: AsyncSession,
marathon_id: int,
message: str,
exclude_user_id: int | None = None
exclude_user_id: int | None = None,
check_setting: str | None = None
) -> int:
"""Send notification to all marathon participants with linked Telegram."""
"""Send notification to all marathon participants with linked Telegram.
Args:
check_setting: If provided, only send to users with this setting enabled.
Options: 'notify_events', 'notify_disputes', 'notify_moderation'
"""
result = await db.execute(
select(User)
.join(Participant, Participant.user_id == User.id)
@@ -100,6 +309,10 @@ class TelegramNotifier:
for user in users:
if exclude_user_id and user.id == exclude_user_id:
continue
# Check notification setting if specified
if check_setting and not getattr(user, check_setting, True):
logger.info(f"[Notify] Skipping user {user.nickname} - {check_setting} is disabled")
continue
if await self.send_message(user.telegram_id, message):
sent_count += 1
@@ -113,7 +326,7 @@ class TelegramNotifier:
event_type: str,
marathon_title: str
) -> int:
"""Notify participants about event start."""
"""Notify participants about event start (respects notify_events setting)."""
event_messages = {
"golden_hour": f"🌟 <b>Начался Golden Hour</b> в «{marathon_title}»!\n\nВсе очки x1.5 в течение часа!",
"jackpot": f"🎰 <b>JACKPOT</b> в «{marathon_title}»!\n\nОчки x3 за следующий сложный челлендж!",
@@ -128,7 +341,9 @@ class TelegramNotifier:
f"📌 Новое событие в «{marathon_title}»!"
)
return await self.notify_marathon_participants(db, marathon_id, message)
return await self.notify_marathon_participants(
db, marathon_id, message, check_setting='notify_events'
)
async def notify_event_end(
self,
@@ -137,7 +352,7 @@ class TelegramNotifier:
event_type: str,
marathon_title: str
) -> int:
"""Notify participants about event end."""
"""Notify participants about event end (respects notify_events setting)."""
event_names = {
"golden_hour": "Golden Hour",
"jackpot": "Jackpot",
@@ -150,7 +365,9 @@ class TelegramNotifier:
event_name = event_names.get(event_type, "Событие")
message = f"⏰ <b>{event_name}</b> в «{marathon_title}» завершён"
return await self.notify_marathon_participants(db, marathon_id, message)
return await self.notify_marathon_participants(
db, marathon_id, message, check_setting='notify_events'
)
async def notify_marathon_start(
self,
@@ -186,7 +403,14 @@ class TelegramNotifier:
challenge_title: str,
assignment_id: int
) -> bool:
"""Notify user about dispute raised on their assignment."""
"""Notify user about dispute raised on their assignment (respects notify_disputes setting)."""
# Check user's notification settings
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user and not user.notify_disputes:
logger.info(f"[Dispute] Skipping user {user.nickname} - notify_disputes is disabled")
return False
logger.info(f"[Dispute] Sending notification to user_id={user_id} for assignment_id={assignment_id}")
dispute_url = f"{settings.FRONTEND_URL}/assignments/{assignment_id}"
@@ -227,7 +451,14 @@ class TelegramNotifier:
challenge_title: str,
is_valid: bool
) -> bool:
"""Notify user about dispute resolution."""
"""Notify user about dispute resolution (respects notify_disputes setting)."""
# Check user's notification settings
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user and not user.notify_disputes:
logger.info(f"[Dispute] Skipping user {user.nickname} - notify_disputes is disabled")
return False
if is_valid:
message = (
f"❌ <b>Спор признан обоснованным</b>\n\n"
@@ -251,7 +482,14 @@ class TelegramNotifier:
marathon_title: str,
game_title: str
) -> bool:
"""Notify user that their proposed game was approved."""
"""Notify user that their proposed game was approved (respects notify_moderation setting)."""
# Check user's notification settings
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user and not user.notify_moderation:
logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled")
return False
message = (
f"✅ <b>Твоя игра одобрена!</b>\n\n"
f"Марафон: {marathon_title}\n"
@@ -267,7 +505,14 @@ class TelegramNotifier:
marathon_title: str,
game_title: str
) -> bool:
"""Notify user that their proposed game was rejected."""
"""Notify user that their proposed game was rejected (respects notify_moderation setting)."""
# Check user's notification settings
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user and not user.notify_moderation:
logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled")
return False
message = (
f"❌ <b>Твоя игра отклонена</b>\n\n"
f"Марафон: {marathon_title}\n"
@@ -276,6 +521,93 @@ class TelegramNotifier:
)
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 (respects notify_moderation setting)."""
# Check user's notification settings
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user and not user.notify_moderation:
logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled")
return False
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 (respects notify_moderation setting)."""
# Check user's notification settings
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user and not user.notify_moderation:
logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled")
return False
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
telegram_notifier = TelegramNotifier()

View File

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

View File

@@ -3,8 +3,9 @@
Restore PostgreSQL database from S3 backup.
Usage:
python restore.py - List available backups
python restore.py <filename> - Restore from specific backup
python restore.py - List available backups
python restore.py <filename> - Restore from backup (cleans DB first)
python restore.py <filename> --no-clean - Restore without cleaning DB first
"""
import gzip
import os
@@ -62,7 +63,48 @@ def list_backups(s3_client) -> list[tuple[str, float, str]]:
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."""
key = f"{config.S3_BACKUP_PREFIX}{filename}"
@@ -79,6 +121,10 @@ def restore_backup(s3_client, filename: str) -> None:
print("Decompressing...")
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}...")
# Build psql command
@@ -124,20 +170,32 @@ def main() -> int:
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
backups = list_backups(s3_client)
if backups:
print(f"\nTo restore, run: python restore.py <filename>")
print("Add --no-clean to skip database cleanup before restore")
else:
print("No backups found.")
return 0
filename = sys.argv[1]
filename = args[0]
# Confirm restore
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()
confirm = input("Type 'yes' to continue: ")
@@ -147,7 +205,7 @@ def main() -> int:
return 0
try:
restore_backup(s3_client, filename)
restore_backup(s3_client, filename, clean_first=clean_first)
return 0
except Exception as e:
print(f"Restore failed: {e}")

View File

@@ -3,7 +3,7 @@ from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery
from keyboards.main_menu import get_main_menu
from keyboards.inline import get_marathons_keyboard, get_marathon_details_keyboard
from keyboards.inline import get_marathons_keyboard, get_marathon_details_keyboard, get_settings_keyboard
from services.api_client import api_client
router = Router()
@@ -197,15 +197,66 @@ async def cmd_settings(message: Message):
)
return
# Get current notification settings
settings = await api_client.get_notification_settings(message.from_user.id)
if not settings:
settings = {"notify_events": True, "notify_disputes": True, "notify_moderation": True}
await message.answer(
"<b>⚙️ Настройки</b>\n\n"
"Управление уведомлениями будет доступно в следующем обновлении.\n\n"
"Сейчас ты получаешь все уведомления:\n"
"• 🌟 События (Golden Hour, Jackpot и др.)\n"
"• 🚀 Старт/финиш марафонов\n"
"• ⚠️ Споры по заданиям\n\n"
"Команды:\n"
"/unlink - Отвязать аккаунт\n"
"/status - Проверить привязку",
"<b>⚙️ Настройки уведомлений</b>\n\n"
"Нажми на категорию, чтобы включить/выключить:\n\n"
"✅ — уведомления включены\n"
"❌ — уведомления выключены\n\n"
"<i>Уведомления о старте/финише марафонов и коды безопасности нельзя отключить.</i>",
reply_markup=get_settings_keyboard(settings)
)
@router.callback_query(F.data.startswith("toggle:"))
async def toggle_notification(callback: CallbackQuery):
"""Toggle notification setting."""
setting_name = callback.data.split(":")[1]
# Get current settings
current_settings = await api_client.get_notification_settings(callback.from_user.id)
if not current_settings:
await callback.answer("Не удалось загрузить настройки", show_alert=True)
return
# Toggle the setting
current_value = current_settings.get(setting_name, True)
new_value = not current_value
# Update on backend
result = await api_client.update_notification_settings(
callback.from_user.id,
{setting_name: new_value}
)
if not result or result.get("error"):
await callback.answer("Не удалось сохранить настройки", show_alert=True)
return
# Update keyboard with new values
await callback.message.edit_reply_markup(
reply_markup=get_settings_keyboard(result)
)
status = "включены" if new_value else "выключены"
setting_names = {
"notify_events": "События",
"notify_disputes": "Споры",
"notify_moderation": "Модерация"
}
await callback.answer(f"{setting_names.get(setting_name, setting_name)}: {status}")
@router.callback_query(F.data == "back_to_menu")
async def back_to_menu(callback: CallbackQuery):
"""Go back to main menu from settings."""
await callback.message.delete()
await callback.message.answer(
"Главное меню",
reply_markup=get_main_menu()
)
await callback.answer()

View File

@@ -40,3 +40,45 @@ def get_marathon_details_keyboard(marathon_id: int) -> InlineKeyboardMarkup:
]
return InlineKeyboardMarkup(inline_keyboard=buttons)
def get_settings_keyboard(settings: dict) -> InlineKeyboardMarkup:
"""Create keyboard for notification settings."""
# Get current values with defaults
notify_events = settings.get("notify_events", True)
notify_disputes = settings.get("notify_disputes", True)
notify_moderation = settings.get("notify_moderation", True)
# Status indicators
events_status = "" if notify_events else ""
disputes_status = "" if notify_disputes else ""
moderation_status = "" if notify_moderation else ""
buttons = [
[
InlineKeyboardButton(
text=f"{events_status} События (Golden Hour, Jackpot...)",
callback_data="toggle:notify_events"
)
],
[
InlineKeyboardButton(
text=f"{disputes_status} Споры",
callback_data="toggle:notify_disputes"
)
],
[
InlineKeyboardButton(
text=f"{moderation_status} Модерация (игры/челленджи)",
callback_data="toggle:notify_moderation"
)
],
[
InlineKeyboardButton(
text="◀️ Назад",
callback_data="back_to_menu"
)
]
]
return InlineKeyboardMarkup(inline_keyboard=buttons)

View File

@@ -124,6 +124,22 @@ class APIClient:
"""Get user's overall statistics."""
return await self._request("GET", f"/telegram/stats/{telegram_id}")
async def get_notification_settings(self, telegram_id: int) -> dict[str, Any] | None:
"""Get user's notification settings."""
return await self._request("GET", f"/telegram/notifications/{telegram_id}")
async def update_notification_settings(
self,
telegram_id: int,
settings: dict[str, bool]
) -> dict[str, Any] | None:
"""Update user's notification settings."""
return await self._request(
"PATCH",
f"/telegram/notifications/{telegram_id}",
json=settings
)
async def close(self):
"""Close the HTTP session."""
if self._session and not self._session.closed:

View File

@@ -9,7 +9,7 @@ services:
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U marathon"]
interval: 5s
@@ -27,9 +27,10 @@ services:
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
OPENAI_API_KEY: ${OPENAI_API_KEY}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-GameMarathonBot}
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-BCMarathonbot}
BOT_API_SECRET: ${BOT_API_SECRET:-}
DEBUG: ${DEBUG:-false}
RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED:-true}
# S3 Storage
S3_ENABLED: ${S3_ENABLED:-false}
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-}
@@ -42,7 +43,7 @@ services:
- ./backend/uploads:/app/uploads
- ./backend/app:/app/app
ports:
- "8000:8000"
- "8002:8000"
depends_on:
db:
condition: service_healthy
@@ -56,7 +57,7 @@ services:
VITE_API_URL: ${VITE_API_URL:-/api/v1}
container_name: marathon-frontend
ports:
- "3000:80"
- "3002:80"
depends_on:
- backend
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 { useAuthStore } from '@/store/auth'
import { ToastContainer, ConfirmModal } from '@/components/ui'
import { BannedScreen } from '@/components/BannedScreen'
// Layout
import { Layout } from '@/components/layout/Layout'
@@ -19,10 +21,22 @@ import { InvitePage } from '@/pages/InvitePage'
import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage'
import { ProfilePage } from '@/pages/ProfilePage'
import { UserProfilePage } from '@/pages/UserProfilePage'
import { StaticContentPage } from '@/pages/StaticContentPage'
import { NotFoundPage } from '@/pages/NotFoundPage'
import { TeapotPage } from '@/pages/TeapotPage'
import { ServerErrorPage } from '@/pages/ServerErrorPage'
// Admin Pages
import {
AdminLayout,
AdminDashboardPage,
AdminUsersPage,
AdminMarathonsPage,
AdminLogsPage,
AdminBroadcastPage,
AdminContentPage,
} from '@/pages/admin'
// Protected route wrapper
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
@@ -46,6 +60,24 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
}
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 (
<>
<ToastContainer />
@@ -57,6 +89,11 @@ function App() {
{/* Public invite page */}
<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
path="login"
element={
@@ -159,6 +196,23 @@ function App() {
<Route path="500" element={<ServerErrorPage />} />
<Route path="error" element={<ServerErrorPage />} />
{/* Admin routes */}
<Route
path="admin"
element={
<ProtectedRoute>
<AdminLayout />
</ProtectedRoute>
}
>
<Route index element={<AdminDashboardPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="marathons" element={<AdminMarathonsPage />} />
<Route path="logs" element={<AdminLogsPage />} />
<Route path="broadcast" element={<AdminBroadcastPage />} />
<Route path="content" element={<AdminContentPage />} />
</Route>
{/* 404 - must be last */}
<Route path="*" element={<NotFoundPage />} />
</Route>

View File

@@ -1,10 +1,26 @@
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 = {
// Dashboard
getDashboard: async (): Promise<DashboardStats> => {
const response = await client.get<DashboardStats>('/admin/dashboard')
return response.data
},
// Users
listUsers: async (skip = 0, limit = 50, search?: string): Promise<AdminUser[]> => {
const params: Record<string, unknown> = { skip, limit }
listUsers: async (skip = 0, limit = 50, search?: string, bannedOnly = false): Promise<AdminUser[]> => {
const params: Record<string, unknown> = { skip, limit, banned_only: bannedOnly }
if (search) params.search = search
const response = await client.get<AdminUser[]>('/admin/users', { params })
return response.data
@@ -24,6 +40,26 @@ export const adminApi = {
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
listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => {
const params: Record<string, unknown> = { skip, limit }
@@ -36,9 +72,97 @@ export const adminApi = {
await client.delete(`/admin/marathons/${id}`)
},
forceFinishMarathon: async (id: number): Promise<void> => {
await client.post(`/admin/marathons/${id}/force-finish`)
},
// Stats
getStats: async (): Promise<PlatformStats> => {
const response = await client.get<PlatformStats>('/admin/stats')
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, media?: File[]): Promise<BroadcastResponse> => {
const formData = new FormData()
formData.append('message', message)
if (media && media.length > 0) {
media.forEach(file => {
formData.append('media', file)
})
}
const response = await client.post<BroadcastResponse>('/admin/broadcast/all', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
return response.data
},
broadcastToMarathon: async (marathonId: number, message: string, media?: File[]): Promise<BroadcastResponse> => {
const formData = new FormData()
formData.append('message', message)
if (media && media.length > 0) {
media.forEach(file => {
formData.append('media', file)
})
}
const response = await client.post<BroadcastResponse>(`/admin/broadcast/marathon/${marathonId}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
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 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 = {
// Get detailed assignment info with proofs and dispute
@@ -14,6 +20,12 @@ export const assignmentsApi = {
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
addComment: async (disputeId: number, text: string): Promise<DisputeComment> => {
const response = await client.post<DisputeComment>(`/disputes/${disputeId}/comments`, { text })
@@ -44,4 +56,95 @@ export const assignmentsApi = {
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 type { TokenResponse, User } from '@/types'
import type { TokenResponse, LoginResponse, User } from '@/types'
export interface RegisterData {
login: string
@@ -18,8 +18,15 @@ export const authApi = {
return response.data
},
login: async (data: LoginData): Promise<TokenResponse> => {
const response = await client.post<TokenResponse>('/auth/login', data)
login: async (data: LoginData): Promise<LoginResponse> => {
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
},

View File

@@ -1,4 +1,5 @@
import axios, { AxiosError } from 'axios'
import { useAuthStore, type BanInfo } from '@/store/auth'
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
@@ -18,15 +19,39 @@ client.interceptors.request.use((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
client.interceptors.response.use(
(response) => response,
(error: AxiosError<{ detail: string }>) => {
// Unauthorized - redirect to login
(error: AxiosError<{ detail: string | BanInfo }>) => {
// Unauthorized - redirect to login (but not for auth endpoints)
if (error.response?.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
window.location.href = '/login'
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('user')
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

View File

@@ -1,11 +1,28 @@
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 {
title: string
download_url: string
genre?: 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 {
@@ -45,6 +62,21 @@ export const gamesApi = {
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> => {
const response = await client.post<Game>(`/games/${id}/approve`)
return response.data
@@ -79,6 +111,11 @@ export const gamesApi = {
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> => {
const data = gameIds?.length ? { game_ids: gameIds } : undefined
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 })
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 { wheelApi } from './wheel'
export { feedApi } from './feed'
export { adminApi } from './admin'
export { adminApi, contentApi } from './admin'
export { eventsApi } from './events'
export { challengesApi } from './challenges'
export { assignmentsApi } from './assignments'

View File

@@ -1,5 +1,5 @@
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 {
title: string
@@ -10,6 +10,8 @@ export interface CreateMarathonData {
game_proposal_mode?: GameProposalMode
}
export type { MarathonUpdate }
export const marathonsApi = {
list: async (): Promise<MarathonListItem[]> => {
const response = await client.get<MarathonListItem[]>('/marathons')
@@ -32,7 +34,7 @@ export const marathonsApi = {
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)
return response.data
},
@@ -78,4 +80,36 @@ export const marathonsApi = {
const response = await client.get<LeaderboardEntry[]>(`/marathons/${id}/leaderboard`)
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

@@ -1,5 +1,5 @@
import client from './client'
import type { User, UserProfilePublic, UserStats, PasswordChangeData } from '@/types'
import type { User, UserProfilePublic, UserStats, PasswordChangeData, NotificationSettings, NotificationSettingsUpdate } from '@/types'
export interface UpdateNicknameData {
nickname: string
@@ -48,4 +48,16 @@ export const usersApi = {
})
return URL.createObjectURL(response.data)
},
// Получить настройки уведомлений
getNotificationSettings: async (): Promise<NotificationSettings> => {
const response = await client.get<NotificationSettings>('/users/me/notifications')
return response.data
},
// Обновить настройки уведомлений
updateNotificationSettings: async (data: NotificationSettingsUpdate): Promise<NotificationSettings> => {
const response = await client.patch<NotificationSettings>('/users/me/notifications', data)
return response.data
},
}

View File

@@ -14,12 +14,19 @@ export const wheelApi = {
complete: async (
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> => {
const formData = new FormData()
if (data.proof_url) formData.append('proof_url', data.proof_url)
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_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, {
headers: { 'Content-Type': 'multipart/form-data' },

View File

@@ -191,14 +191,15 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
const isEvent = isEventActivity(activity.type)
const { title, details, extra } = formatActivityMessage(activity)
// Get assignment_id and dispute status for complete activities
const activityData = activity.data as { assignment_id?: number; dispute_status?: string } | null
// Get assignment_id, dispute status, and is_redo for complete activities
const activityData = activity.data as { assignment_id?: number; dispute_status?: string; is_redo?: boolean } | null
const assignmentId = activity.type === 'complete' && activityData?.assignment_id
? activityData.assignment_id
: null
const disputeStatus = activity.type === 'complete' && activityData?.dispute_status
? activityData.dispute_status
: null
const isRedo = activity.type === 'complete' && activityData?.is_redo === true
// Determine accent color based on activity type
const getAccentConfig = () => {
@@ -323,6 +324,12 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
<ExternalLink className="w-3 h-3" />
Детали
</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' && (
<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" />

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 { Gamepad2, Loader2 } from 'lucide-react'
@@ -9,27 +9,43 @@ interface SpinWheelProps {
disabled?: boolean
}
const SPIN_DURATION = 5000 // ms
const EXTRA_ROTATIONS = 5
const SPIN_DURATION = 6000 // ms - увеличено для более плавного замедления
const EXTRA_ROTATIONS = 7 // больше оборотов для эффекта инерции
// Цветовая палитра секторов
// Пороги для адаптивного отображения
const TEXT_THRESHOLD = 16 // До 16 игр - показываем текст
const LINES_THRESHOLD = 40 // До 40 игр - показываем разделители
// Цветовая палитра секторов (расширенная для большего количества)
const SECTOR_COLORS = [
{ bg: '#0d9488', border: '#14b8a6' }, // teal
{ bg: '#7c3aed', border: '#8b5cf6' }, // violet
{ bg: '#0891b2', border: '#06b6d4' }, // cyan
{ bg: '#c026d3', border: '#d946ef' }, // fuchsia
{ bg: '#059669', border: '#10b981' }, // emerald
{ bg: '#7c2d12', border: '#ea580c' }, // orange
{ bg: '#ea580c', border: '#f97316' }, // orange
{ bg: '#1d4ed8', border: '#3b82f6' }, // blue
{ 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) {
const [isSpinning, setIsSpinning] = useState(false)
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 centerY = wheelSize / 2
const radius = wheelSize / 2 - 10
@@ -102,11 +118,16 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
const fullRotations = EXTRA_ROTATIONS * 360
const finalAngle = fullRotations + (360 - targetSectorMidAngle) + baseRotation
setRotation(rotation + finalAngle)
const newRotation = rotation + finalAngle
setStartRotation(rotation)
setTargetRotation(newRotation)
setSpinStartTime(Date.now())
setRotation(newRotation)
// Ждём окончания анимации
setTimeout(() => {
setIsSpinning(false)
setSpinStartTime(null)
onSpinComplete(resultGame)
}, SPIN_DURATION)
}, [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) + '...'
}
// Функция для вычисления игры под указателем по углу
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(() => {
return games.map((game, index) => {
const color = SECTOR_COLORS[index % SECTOR_COLORS.length]
const path = createSectorPath(index, games.length)
const textPos = getTextPosition(index, games.length)
const maxTextLength = games.length > 8 ? 10 : games.length > 5 ? 14 : 18
const maxTextLength = games.length > 12 ? 8 : games.length > 8 ? 10 : games.length > 5 ? 14 : 18
return { game, color, path, textPos, maxTextLength }
})
@@ -213,7 +288,8 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
transform: `rotate(${rotation}deg)`,
transitionProperty: isSpinning ? 'transform' : 'none',
transitionDuration: isSpinning ? `${SPIN_DURATION}ms` : '0ms',
transitionTimingFunction: 'cubic-bezier(0.17, 0.67, 0.12, 0.99)',
// Инерционное вращение: быстрый старт, долгое плавное замедление
transitionTimingFunction: 'cubic-bezier(0.12, 0.9, 0.15, 1)',
}}
>
<defs>
@@ -230,38 +306,42 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
<path
d={path}
fill={color.bg}
stroke={color.border}
strokeWidth="2"
stroke={showLines ? color.border : 'transparent'}
strokeWidth={showLines ? "1" : "0"}
filter="url(#sectorShadow)"
/>
{/* Текст названия игры */}
<text
x={textPos.x}
y={textPos.y}
transform={`rotate(${textPos.rotation}, ${textPos.x}, ${textPos.y})`}
textAnchor="middle"
dominantBaseline="middle"
fill="white"
fontSize={games.length > 10 ? "10" : games.length > 6 ? "11" : "13"}
fontWeight="bold"
style={{
textShadow: '0 1px 3px rgba(0,0,0,0.8)',
pointerEvents: 'none',
}}
>
{truncateText(game.title, maxTextLength)}
</text>
{/* Текст названия игры - только для небольшого количества */}
{showText && (
<text
x={textPos.x}
y={textPos.y}
transform={`rotate(${textPos.rotation}, ${textPos.x}, ${textPos.y})`}
textAnchor="middle"
dominantBaseline="middle"
fill="white"
fontSize={games.length > 12 ? "9" : games.length > 8 ? "10" : games.length > 6 ? "11" : "13"}
fontWeight="bold"
style={{
textShadow: '0 1px 3px rgba(0,0,0,0.8)',
pointerEvents: 'none',
}}
>
{truncateText(game.title, maxTextLength)}
</text>
)}
{/* Разделительная линия */}
<line
x1={centerX}
y1={centerY}
x2={centerX + radius * Math.cos((index * sectorAngle - 90) * Math.PI / 180)}
y2={centerY + radius * Math.sin((index * sectorAngle - 90) * Math.PI / 180)}
stroke="rgba(255,255,255,0.3)"
strokeWidth="1"
/>
{/* Разделительная линия - только для среднего количества */}
{showLines && (
<line
x1={centerX}
y1={centerY}
x2={centerX + radius * Math.cos((index * sectorAngle - 90) * Math.PI / 180)}
y2={centerY + radius * Math.sin((index * sectorAngle - 90) * Math.PI / 180)}
stroke="rgba(255,255,255,0.2)"
strokeWidth="1"
/>
)}
</g>
))}
@@ -322,6 +402,21 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
)}
</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={`
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 { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
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 { clsx } from 'clsx'
@@ -74,6 +74,21 @@ export function Layout() {
<span>Марафоны</span>
</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">
<Link
to="/profile"
@@ -144,6 +159,20 @@ export function Layout() {
<Trophy className="w-5 h-5" />
<span>Марафоны</span>
</Link>
{user?.role === 'admin' && (
<Link
to="/admin"
className={clsx(
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
location.pathname.startsWith('/admin')
? 'text-purple-400 bg-purple-500/10'
: 'text-gray-300 hover:text-white hover:bg-dark-700'
)}
>
<Shield className="w-5 h-5" />
<span>Админка</span>
</Link>
)}
<Link
to="/profile"
className={clsx(
@@ -205,7 +234,13 @@ export function Layout() {
Игровой Марафон &copy; {new Date().getFullYear()}
</span>
</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>
</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 = {
sm: 'w-4 h-4',
md: 'w-5 h-5',
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]
return (
@@ -118,13 +128,9 @@ export const NeonButton = forwardRef<HTMLButtonElement, NeonButtonProps>(
{...props}
>
{isLoading && <Loader2 className={clsx(iconSizes[size], 'animate-spin')} />}
{!isLoading && icon && iconPosition === 'left' && (
<span className={iconSizes[size]}>{icon}</span>
)}
{!isLoading && icon && iconPosition === 'left' && icon}
{children}
{!isLoading && icon && iconPosition === 'right' && (
<span className={iconSizes[size]}>{icon}</span>
)}
{!isLoading && icon && iconPosition === 'right' && icon}
</button>
)
}

View File

@@ -8,7 +8,7 @@ import { useToast } from '@/store/toast'
import {
ArrowLeft, Loader2, ExternalLink, Image, MessageSquare,
ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle,
Send, Flag, Gamepad2, Zap, Trophy
Send, Flag, Gamepad2, Zap, Trophy, Download, ChevronLeft, ChevronRight, X
} from 'lucide-react'
export function AssignmentDetailPage() {
@@ -23,11 +23,30 @@ export function AssignmentDetailPage() {
const [proofMediaBlobUrl, setProofMediaBlobUrl] = useState<string | 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
const [showDisputeForm, setShowDisputeForm] = useState(false)
const [disputeReason, setDisputeReason] = useState('')
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
const [commentText, setCommentText] = useState('')
const [isAddingComment, setIsAddingComment] = useState(false)
@@ -38,10 +57,24 @@ export function AssignmentDetailPage() {
useEffect(() => {
loadAssignment()
return () => {
// Cleanup blob URL on unmount
// Cleanup blob URLs on unmount
if (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])
@@ -53,8 +86,20 @@ export function AssignmentDetailPage() {
const data = await assignmentsApi.getDetail(parseInt(id))
setAssignment(data)
// Load proof media if exists
if (data.proof_image_url) {
// Load proof files if exists (new multi-file support)
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 {
const { url, type } = await assignmentsApi.getProofMediaUrl(parseInt(id))
setProofMediaBlobUrl(url)
@@ -63,6 +108,39 @@ export function AssignmentDetailPage() {
// 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) {
const error = err as { response?: { data?: { detail?: string } } }
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) => {
if (!assignment?.dispute) return
@@ -142,6 +251,24 @@ export function AssignmentDetailPage() {
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) => {
switch (status) {
case 'completed':
@@ -215,39 +342,76 @@ export function AssignmentDetailPage() {
</div>
</div>
{/* Challenge info */}
{/* Challenge/Playthrough info */}
<GlassCard variant="neon">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 border border-neon-500/20 flex items-center justify-center">
<Gamepad2 className="w-7 h-7 text-neon-400" />
<div className={`w-14 h-14 rounded-xl border flex items-center justify-center ${
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>
<p className="text-gray-400 text-sm">{assignment.challenge.game.title}</p>
<h2 className="text-xl font-bold text-white">{assignment.challenge.title}</h2>
<p className="text-gray-400 text-sm">
{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>
<span className={`px-3 py-1.5 rounded-full text-sm font-medium border flex items-center gap-1.5 ${status.color}`}>
{status.icon}
{status.text}
</span>
<div 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}`}>
{status.icon}
{status.text}
</span>
</div>
</div>
<p className="text-gray-300 mb-4">{assignment.challenge.description}</p>
<p className="text-gray-300 mb-4">
{assignment.is_playthrough
? assignment.playthrough_info?.description
: assignment.challenge?.description}
</p>
<div className="flex flex-wrap gap-2 mb-4">
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30 flex items-center gap-1.5">
<Trophy className="w-4 h-4" />
+{assignment.challenge.points} очков
+{assignment.is_playthrough
? assignment.playthrough_info?.points
: assignment.challenge?.points} очков
</span>
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
{assignment.challenge.difficulty}
</span>
{assignment.challenge.estimated_time && (
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600 flex items-center gap-1.5">
<Clock className="w-4 h-4" />
~{assignment.challenge.estimated_time} мин
</span>
{!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">
{assignment.challenge.difficulty}
</span>
{assignment.challenge.estimated_time && (
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600 flex items-center gap-1.5">
<Clock className="w-4 h-4" />
~{assignment.challenge.estimated_time} мин
</span>
)}
</>
)}
{/* 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>
@@ -271,6 +435,224 @@ export function AssignmentDetailPage() {
</div>
</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 */}
<GlassCard>
<div className="flex items-center gap-3 mb-4">
@@ -283,8 +665,47 @@ export function AssignmentDetailPage() {
</div>
</div>
{/* Proof media (image or video) */}
{assignment.proof_image_url && (
{/* Proof files gallery (multiple proofs) */}
{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">
{proofMediaBlobUrl ? (
proofMediaType === 'video' ? (
@@ -295,11 +716,16 @@ export function AssignmentDetailPage() {
preload="metadata"
/>
) : (
<img
src={proofMediaBlobUrl}
alt="Proof"
className="w-full max-h-96 object-contain bg-dark-900"
/>
<button
onClick={() => openLightbox([{ url: proofMediaBlobUrl, type: 'image' }], 0)}
className="w-full"
>
<img
src={proofMediaBlobUrl}
alt="Proof"
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">
@@ -332,7 +758,7 @@ export function AssignmentDetailPage() {
</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="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" />
@@ -548,6 +974,69 @@ export function AssignmentDetailPage() {
</div>
</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>
)
}

View File

@@ -1,12 +1,13 @@
import { useState } from 'react'
import { useState, useRef } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { marathonsApi } from '@/api'
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 { useToast } from '@/store/toast'
const createSchema = z.object({
title: z.string().min(1, 'Название обязательно').max(100),
@@ -21,8 +22,12 @@ type CreateForm = z.infer<typeof createSchema>
export function CreateMarathonPage() {
const navigate = useNavigate()
const toast = useToast()
const [isLoading, setIsLoading] = useState(false)
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 {
register,
@@ -42,6 +47,38 @@ export function CreateMarathonPage() {
const isPublic = watch('is_public')
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) => {
setIsLoading(true)
setError(null)
@@ -54,6 +91,16 @@ export function CreateMarathonPage() {
is_public: data.is_public,
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`)
} catch (err: unknown) {
const apiError = err as { response?: { data?: { detail?: string } } }
@@ -94,6 +141,57 @@ export function CreateMarathonPage() {
</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 */}
<div className="space-y-4">
<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 { marathonsApi } from '@/api'
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({
login: z.string().min(3, 'Логин должен быть не менее 3 символов'),
@@ -17,8 +17,9 @@ type LoginForm = z.infer<typeof loginSchema>
export function LoginPage() {
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 [twoFACode, setTwoFACode] = useState('')
const {
register,
@@ -32,7 +33,12 @@ export function LoginPage() {
setSubmitError(null)
clearError()
try {
await login(data)
const result = await login(data)
// If 2FA required, don't navigate
if (result.requires2FA) {
return
}
// Check for pending invite code
const pendingCode = consumePendingInviteCode()
@@ -48,10 +54,29 @@ export function LoginPage() {
navigate('/marathons')
} 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 = [
{ icon: <Trophy className="w-5 h-5" />, text: 'Соревнуйтесь с друзьями' },
{ icon: <Target className="w-5 h-5" />, text: 'Выполняйте челленджи' },
@@ -113,61 +138,120 @@ export function LoginPage() {
{/* Form Block (right) */}
<GlassCard className="p-8">
{/* Header */}
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-white mb-2">Добро пожаловать!</h2>
<p className="text-gray-400">Войдите, чтобы продолжить</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{(submitError || error) && (
<div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span>{submitError || error}</span>
{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>
)}
<Input
label="Логин"
placeholder="Введите логин"
error={errors.login?.message}
autoComplete="username"
{...register('login')}
/>
{/* 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="Пароль"
type="password"
placeholder="Введите пароль"
error={errors.password?.message}
autoComplete="current-password"
{...register('password')}
/>
<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}
icon={<LogIn className="w-5 h-5" />}
>
Войти
</NeonButton>
</form>
<NeonButton
type="submit"
className="w-full"
size="lg"
isLoading={isLoading}
disabled={twoFACode.length !== 6}
icon={<Shield className="w-5 h-5" />}
>
Подтвердить
</NeonButton>
</form>
{/* Footer */}
<div className="mt-6 pt-6 border-t border-dark-600 text-center">
<p className="text-gray-400 text-sm">
Нет аккаунта?{' '}
<Link
to="/register"
className="text-neon-400 hover:text-neon-300 transition-colors font-medium"
>
Зарегистрироваться
</Link>
</p>
</div>
{/* Back button */}
<div className="mt-6 pt-6 border-t border-dark-600 text-center">
<button
onClick={handleCancel2FA}
className="text-gray-400 hover:text-white transition-colors text-sm flex items-center justify-center gap-2 mx-auto"
>
<ArrowLeft className="w-4 h-4" />
Вернуться к входу
</button>
</div>
</>
) : (
// Regular Login Form
<>
{/* Header */}
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-white mb-2">Добро пожаловать!</h2>
<p className="text-gray-400">Войдите, чтобы продолжить</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{(submitError || error) && (
<div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span>{submitError || error}</span>
</div>
)}
<Input
label="Логин"
placeholder="Введите логин"
error={errors.login?.message}
autoComplete="username"
{...register('login')}
/>
<Input
label="Пароль"
type="password"
placeholder="Введите пароль"
error={errors.password?.message}
autoComplete="current-password"
{...register('password')}
/>
<NeonButton
type="submit"
className="w-full"
size="lg"
isLoading={isLoading}
icon={<LogIn className="w-5 h-5" />}
>
Войти
</NeonButton>
</form>
{/* Footer */}
<div className="mt-6 pt-6 border-t border-dark-600 text-center">
<p className="text-gray-400 text-sm">
Нет аккаунта?{' '}
<Link
to="/register"
className="text-neon-400 hover:text-neon-300 transition-colors font-medium"
>
Зарегистрироваться
</Link>
</p>
</div>
</>
)}
</GlassCard>
</div>

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { marathonsApi, eventsApi, challengesApi } from '@/api'
import type { Marathon, ActiveEvent, Challenge } from '@/types'
import type { Marathon, ActiveEvent, Challenge, MarathonDispute } from '@/types'
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { useAuthStore } from '@/store/auth'
import { useToast } from '@/store/toast'
@@ -9,13 +9,16 @@ import { useConfirm } from '@/store/confirm'
import { EventBanner } from '@/components/EventBanner'
import { EventControl } from '@/components/EventControl'
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
import { MarathonSettingsModal } from '@/components/MarathonSettingsModal'
import {
Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2,
Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag,
Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2, Sparkles
Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2, Sparkles,
AlertTriangle, Clock, CheckCircle, XCircle, ThumbsUp, ThumbsDown, ExternalLink, User
} from 'lucide-react'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
import { TelegramBotBanner } from '@/components/TelegramBotBanner'
export function MarathonPage() {
const { id } = useParams<{ id: string }>()
@@ -34,12 +37,26 @@ export function MarathonPage() {
const [showEventControl, setShowEventControl] = useState(false)
const [showChallenges, setShowChallenges] = useState(false)
const [expandedGameId, setExpandedGameId] = useState<number | null>(null)
const [showSettings, setShowSettings] = useState(false)
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(() => {
loadMarathon()
}, [id])
useEffect(() => {
if (showDisputes) {
loadDisputes()
}
}, [showDisputes, disputeFilter])
const loadMarathon = async () => {
if (!id) return
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 = () => {
if (!marathon) return ''
return `${window.location.origin}/invite/${marathon.invite_code}`
@@ -189,8 +257,22 @@ export function MarathonPage() {
{/* Hero Banner */}
<div className="relative rounded-2xl overflow-hidden mb-8">
{/* Background */}
<div className="absolute inset-0 bg-gradient-to-br from-neon-500/10 via-dark-800 to-accent-500/10" />
<div className="absolute inset-0 bg-[linear-gradient(rgba(34,211,238,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(34,211,238,0.02)_1px,transparent_1px)] bg-[size:50px_50px]" />
{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-[linear-gradient(rgba(34,211,238,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(34,211,238,0.02)_1px,transparent_1px)] bg-[size:50px_50px]" />
</>
)}
<div className="relative p-8">
<div className="flex flex-col md:flex-row justify-between items-start gap-6">
@@ -226,8 +308,8 @@ export function MarathonPage() {
{marathon.status === 'preparing' && isOrganizer && (
<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>
</Link>
)}
@@ -265,6 +347,15 @@ export function MarathonPage() {
</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 && (
<NeonButton
variant="ghost"
@@ -316,6 +407,9 @@ export function MarathonPage() {
/>
</div>
{/* Telegram Bot Banner */}
<TelegramBotBanner />
{/* Active event banner */}
{marathon.status === 'active' && activeEvent?.event && (
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
@@ -356,6 +450,196 @@ export function MarathonPage() {
</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 */}
{marathon.status !== 'finished' && (
<GlassCard>
@@ -529,6 +813,14 @@ export function MarathonPage() {
</div>
)}
</div>
{/* Settings Modal */}
<MarathonSettingsModal
marathon={marathon}
isOpen={showSettings}
onClose={() => setShowSettings(false)}
onUpdate={setMarathon}
/>
</div>
)
}

View File

@@ -4,6 +4,8 @@ import { marathonsApi } from '@/api'
import type { MarathonListItem } from '@/types'
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
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 { ru } from 'date-fns/locale'
@@ -145,6 +147,16 @@ export function MarathonsPage() {
</div>
)}
{/* Announcement Banner */}
<div className="mb-4">
<AnnouncementBanner />
</div>
{/* Telegram Bot Banner */}
<div className="mb-8">
<TelegramBotBanner />
</div>
{/* Join marathon */}
{showJoinSection && (
<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 gap-4">
{/* Icon */}
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center border border-neon-500/20 group-hover:border-neon-500/40 transition-colors">
<Gamepad2 className="w-7 h-7 text-neon-400" />
</div>
{/* Cover or Icon */}
{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" />
</div>
)}
{/* Info */}
<div>

View File

@@ -5,7 +5,7 @@ import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequ
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { SpinWheel } from '@/components/SpinWheel'
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 { useConfirm } from '@/store/confirm'
@@ -25,7 +25,7 @@ export function PlayPage() {
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [proofFile, setProofFile] = useState<File | null>(null)
const [proofFiles, setProofFiles] = useState<File[]>([])
const [proofUrl, setProofUrl] = useState('')
const [comment, setComment] = useState('')
const [isCompleting, setIsCompleting] = useState(false)
@@ -55,6 +55,15 @@ export function PlayPage() {
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 eventFileInputRef = useRef<HTMLInputElement>(null)
@@ -168,17 +177,17 @@ export function PlayPage() {
const loadData = async () => {
if (!id) return
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)),
wheelApi.getCurrentAssignment(parseInt(id)),
gamesApi.list(parseInt(id), 'approved'),
gamesApi.getAvailableGames(parseInt(id)),
eventsApi.getActive(parseInt(id)),
eventsApi.getEventAssignment(parseInt(id)),
assignmentsApi.getReturnedAssignments(parseInt(id)),
])
setMarathon(marathonData)
setCurrentAssignment(assignment)
setGames(gamesData)
setGames(availableGamesData)
setActiveEvent(eventData)
setEventAssignment(eventAssignmentData)
setReturnedAssignments(returnedData)
@@ -219,20 +228,30 @@ export function PlayPage() {
const handleComplete = async () => {
if (!currentAssignment) return
if (!proofFile && !proofUrl) {
toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
return
// 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('Пожалуйста, предоставьте доказательство (файл или ссылку)')
return
}
}
setIsCompleting(true)
try {
const result = await wheelApi.complete(currentAssignment.id, {
proof_file: proofFile || undefined,
proof_files: proofFiles.length > 0 ? proofFiles : undefined,
proof_url: proofUrl || undefined,
comment: comment || undefined,
})
toast.success(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`)
setProofFile(null)
setProofFiles([])
setProofUrl('')
setComment('')
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 () => {
if (!eventAssignment?.assignment) return
if (!eventProofFile && !eventProofUrl) {
@@ -529,12 +581,23 @@ export function PlayPage() {
>
<div className="flex items-start justify-between">
<div>
<p className="text-white font-medium">{ra.challenge.title}</p>
<p className="text-gray-400 text-sm">{ra.challenge.game.title}</p>
{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-gray-400 text-sm">{ra.challenge.game.title}</p>
</>
) : null}
</div>
<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}
</span>
{!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">
+{ra.challenge.points}
</span>
)}
</div>
<p className="text-orange-300 text-xs mt-2">
Причина: {ra.dispute_reason}
@@ -640,28 +703,28 @@ export function PlayPage() {
<div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Игра</p>
<p className="text-xl font-bold text-white">
{eventAssignment.assignment.challenge.game.title}
{eventAssignment.assignment.challenge?.game.title}
</p>
</div>
<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">
{eventAssignment.assignment.challenge.title}
{eventAssignment.assignment.challenge?.title}
</p>
<p className="text-gray-300">
{eventAssignment.assignment.challenge.description}
{eventAssignment.assignment.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">
+{eventAssignment.assignment.challenge.points} очков
+{eventAssignment.assignment.challenge?.points} очков
</span>
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
{eventAssignment.assignment.challenge.difficulty}
{eventAssignment.assignment.challenge?.difficulty}
</span>
{eventAssignment.assignment.challenge.estimated_time && (
{eventAssignment.assignment.challenge?.estimated_time && (
<span className="text-gray-400 text-sm flex items-center gap-1">
<Clock className="w-4 h-4" />
~{eventAssignment.assignment.challenge.estimated_time} мин
@@ -669,7 +732,7 @@ export function PlayPage() {
)}
</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">
<p className="text-sm text-gray-400">
<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>
<label className="block text-sm font-medium text-gray-300 mb-2">
Загрузить доказательство ({eventAssignment.assignment.challenge.proof_type})
Загрузить доказательство ({eventAssignment.assignment.challenge?.proof_type})
</label>
<input
@@ -891,74 +954,318 @@ export function PlayPage() {
<>
<GlassCard variant="neon">
<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>
</div>
<div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Игра</p>
<p className="text-xl font-bold text-white">
{currentAssignment.challenge.game.title}
</p>
</div>
<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 className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
{currentAssignment.challenge.difficulty}
</span>
{currentAssignment.challenge.estimated_time && (
<span className="text-gray-400 text-sm flex items-center gap-1">
<Clock className="w-4 h-4" />
~{currentAssignment.challenge.estimated_time} мин
</span>
)}
</div>
{currentAssignment.challenge.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.challenge.proof_hint}
<div className="flex items-center justify-between gap-3 flex-wrap">
<p className="text-xl font-bold text-white">
{currentAssignment.is_playthrough
? currentAssignment.game?.title
: currentAssignment.challenge?.game.title}
</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>
{currentAssignment.is_playthrough ? (
// Playthrough task
<>
<div className="mb-4">
<p className="text-gray-400 text-sm mb-1">Задача</p>
<p className="text-xl font-bold text-accent-400 mb-2">
Пройти игру
</p>
<p className="text-gray-300">
{currentAssignment.playthrough_info?.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.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 className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
{currentAssignment.challenge?.difficulty}
</span>
{currentAssignment.challenge?.estimated_time && (
<span className="text-gray-400 text-sm flex items-center gap-1">
<Clock className="w-4 h-4" />
~{currentAssignment.challenge.estimated_time} мин
</span>
)}
</div>
{currentAssignment.challenge?.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.challenge.proof_hint}
</p>
</div>
)}
</>
)}
<div className="space-y-4 mb-6">
<div>
<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>
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*"
multiple
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 ? (
<div 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">{proofFile.name}</span>
<button
onClick={() => setProofFile(null)}
className="p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
{proofFiles.length > 0 ? (
<div className="space-y-2">
{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
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"
>
<X className="w-4 h-4" />
</button>
</div>
))}
<NeonButton
variant="outline"
className="w-full"
onClick={() => fileInputRef.current?.click()}
icon={<Upload className="w-4 h-4" />}
>
<X className="w-4 h-4" />
</button>
Добавить ещё файлы
</NeonButton>
</div>
) : (
<div>
@@ -968,10 +1275,10 @@ export function PlayPage() {
onClick={() => fileInputRef.current?.click()}
icon={<Upload className="w-4 h-4" />}
>
Выбрать файл
Выбрать файлы
</NeonButton>
<p className="text-xs text-gray-500 mt-2 text-center">
Макс. 15 МБ для изображений, 30 МБ для видео
Можно выбрать несколько файлов. Макс. 15 МБ для изображений, 30 МБ для видео
</p>
</div>
)}
@@ -1000,7 +1307,10 @@ export function PlayPage() {
className="flex-1"
onClick={handleComplete}
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" />}
>
Выполнено

View File

@@ -12,7 +12,8 @@ import {
import {
User, Camera, Trophy, Target, CheckCircle, Flame,
Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
Eye, EyeOff, Save, KeyRound, Shield
Eye, EyeOff, Save, KeyRound, Shield, Bell, Sparkles,
AlertTriangle, FileCheck
} from 'lucide-react'
// Schemas
@@ -51,6 +52,12 @@ export function ProfilePage() {
const [isPolling, setIsPolling] = useState(false)
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Notification settings state
const [notifyEvents, setNotifyEvents] = useState(user?.notify_events ?? true)
const [notifyDisputes, setNotifyDisputes] = useState(user?.notify_disputes ?? true)
const [notifyModeration, setNotifyModeration] = useState(user?.notify_moderation ?? true)
const [notificationUpdating, setNotificationUpdating] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
// Forms
@@ -265,6 +272,29 @@ export function ProfilePage() {
}
}
// Update notification setting
const handleNotificationToggle = async (
setting: 'notify_events' | 'notify_disputes' | 'notify_moderation',
currentValue: boolean,
setValue: (value: boolean) => void
) => {
setNotificationUpdating(setting)
const newValue = !currentValue
setValue(newValue)
try {
await usersApi.updateNotificationSettings({ [setting]: newValue })
updateUser({ [setting]: newValue })
toast.success('Настройки сохранены')
} catch {
// Revert on error
setValue(currentValue)
toast.error('Не удалось сохранить настройки')
} finally {
setNotificationUpdating(null)
}
}
const isLinked = !!user?.telegram_id
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
@@ -544,6 +574,109 @@ export function ProfilePage() {
</form>
)}
</GlassCard>
{/* Notifications */}
{isLinked && (
<GlassCard>
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 rounded-xl bg-neon-500/20 flex items-center justify-center">
<Bell className="w-6 h-6 text-neon-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">Уведомления</h2>
<p className="text-sm text-gray-400">Настройте типы уведомлений в Telegram</p>
</div>
</div>
<div className="space-y-4">
{/* Events toggle */}
<button
onClick={() => handleNotificationToggle('notify_events', notifyEvents, setNotifyEvents)}
disabled={notificationUpdating !== null}
className="w-full flex items-center justify-between p-4 bg-dark-700/50 rounded-xl border border-dark-600 hover:border-dark-500 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
<Sparkles className="w-5 h-5 text-yellow-400" />
</div>
<div className="text-left">
<p className="text-white font-medium">События</p>
<p className="text-sm text-gray-400">Golden Hour, Jackpot, Double Risk и др.</p>
</div>
</div>
<div className={`
w-12 h-7 rounded-full transition-colors relative flex-shrink-0
${notifyEvents ? 'bg-neon-500' : 'bg-dark-600'}
${notificationUpdating === 'notify_events' ? 'opacity-50' : ''}
`}>
<div className={`
absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-transform
${notifyEvents ? 'left-6' : 'left-1'}
`} />
</div>
</button>
{/* Disputes toggle */}
<button
onClick={() => handleNotificationToggle('notify_disputes', notifyDisputes, setNotifyDisputes)}
disabled={notificationUpdating !== null}
className="w-full flex items-center justify-between p-4 bg-dark-700/50 rounded-xl border border-dark-600 hover:border-dark-500 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-orange-400" />
</div>
<div className="text-left">
<p className="text-white font-medium">Споры</p>
<p className="text-sm text-gray-400">Оспаривания заданий и их решения</p>
</div>
</div>
<div className={`
w-12 h-7 rounded-full transition-colors relative flex-shrink-0
${notifyDisputes ? 'bg-neon-500' : 'bg-dark-600'}
${notificationUpdating === 'notify_disputes' ? 'opacity-50' : ''}
`}>
<div className={`
absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-transform
${notifyDisputes ? 'left-6' : 'left-1'}
`} />
</div>
</button>
{/* Moderation toggle */}
<button
onClick={() => handleNotificationToggle('notify_moderation', notifyModeration, setNotifyModeration)}
disabled={notificationUpdating !== null}
className="w-full flex items-center justify-between p-4 bg-dark-700/50 rounded-xl border border-dark-600 hover:border-dark-500 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center">
<FileCheck className="w-5 h-5 text-green-400" />
</div>
<div className="text-left">
<p className="text-white font-medium">Модерация</p>
<p className="text-sm text-gray-400">Одобрение/отклонение игр и челленджей</p>
</div>
</div>
<div className={`
w-12 h-7 rounded-full transition-colors relative flex-shrink-0
${notifyModeration ? 'bg-neon-500' : 'bg-dark-600'}
${notificationUpdating === 'notify_moderation' ? 'opacity-50' : ''}
`}>
<div className={`
absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-transform
${notifyModeration ? 'left-6' : 'left-1'}
`} />
</div>
</button>
{/* Info about mandatory notifications */}
<p className="text-xs text-gray-500 mt-4">
Уведомления о старте/финише марафонов и коды безопасности нельзя отключить.
</p>
</div>
</GlassCard>
)}
</div>
)
}

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,813 @@
import { useState, useEffect, useMemo, useRef, useCallback } 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, Search, Eye, MessageSquare, ChevronDown, X, Image, Film, Trash2, Plus } from 'lucide-react'
interface MediaItem {
file: File
preview: string
type: 'photo' | 'video'
}
// Telegram supported tags for reference
const TELEGRAM_TAGS = [
{ tag: '<b>', description: 'Жирный текст', example: '<b>жирный</b>' },
{ tag: '<i>', description: 'Курсив', example: '<i>курсив</i>' },
{ tag: '<u>', description: 'Подчёркнутый', example: '<u>подчёркнутый</u>' },
{ tag: '<s>', description: 'Зачёркнутый', example: '<s>зачёркнутый</s>' },
{ tag: '<code>', description: 'Моноширинный', example: '<code>код</code>' },
{ tag: '<pre>', description: 'Блок кода', example: '<pre>блок кода</pre>' },
{ tag: '<a>', description: 'Ссылка', example: '<a href="url">текст</a>' },
{ tag: '<tg-spoiler>', description: 'Спойлер', example: '<tg-spoiler>скрытый текст</tg-spoiler>' },
{ tag: '<blockquote>', description: 'Цитата', example: '<blockquote>цитата</blockquote>' },
]
// Convert Telegram HTML to web-safe HTML for preview
function telegramToHtml(text: string): string {
let html = text
// Convert tg-spoiler to span with blur effect
.replace(/<tg-spoiler>/g, '<span class="tg-spoiler">')
.replace(/<\/tg-spoiler>/g, '</span>')
// Convert newlines to <br> tags (but not inside <pre> blocks)
.replace(/\n/g, '<br>')
return html
}
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 [marathonSearch, setMarathonSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'preparing' | 'finished'>('all')
const [showTagsHelp, setShowTagsHelp] = useState(false)
const [mediaItems, setMediaItems] = useState<MediaItem[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
// Undo/Redo history
const [history, setHistory] = useState<string[]>([''])
const [historyIndex, setHistoryIndex] = useState(0)
const isUndoRedo = useRef(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const toast = useToast()
// Load marathons on mount and when switching to marathon target
useEffect(() => {
// Always load marathons on mount so they're ready when user switches
loadMarathons()
}, [])
useEffect(() => {
if (targetType === 'marathon' && marathons.length === 0) {
loadMarathons()
}
}, [targetType])
const loadMarathons = async () => {
setLoadingMarathons(true)
try {
const data = await adminApi.listMarathons(0, 200)
console.log('Loaded marathons:', data)
setMarathons(data)
} catch (err) {
console.error('Failed to load marathons:', err)
toast.error('Ошибка загрузки марафонов')
} finally {
setLoadingMarathons(false)
}
}
// Handle message change with history
const handleMessageChange = useCallback((newValue: string) => {
if (isUndoRedo.current) {
isUndoRedo.current = false
return
}
setMessage(newValue)
// Add to history (debounced - only if different from last entry)
setHistory(prev => {
const newHistory = prev.slice(0, historyIndex + 1)
if (newHistory[newHistory.length - 1] !== newValue) {
return [...newHistory, newValue]
}
return newHistory
})
setHistoryIndex(prev => prev + 1)
}, [historyIndex])
// Undo function
const undo = useCallback(() => {
if (historyIndex > 0) {
isUndoRedo.current = true
const newIndex = historyIndex - 1
setHistoryIndex(newIndex)
setMessage(history[newIndex])
}
}, [history, historyIndex])
// Redo function
const redo = useCallback(() => {
if (historyIndex < history.length - 1) {
isUndoRedo.current = true
const newIndex = historyIndex + 1
setHistoryIndex(newIndex)
setMessage(history[newIndex])
}
}, [history, historyIndex])
// Handle keyboard shortcuts
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 'z') {
e.preventDefault()
if (e.shiftKey) {
redo()
} else {
undo()
}
} else if (e.key === 'y') {
e.preventDefault()
redo()
}
}
}, [undo, redo])
// Handle media file selection (multiple)
const handleMediaSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
// Check max limit (10 files for Telegram media group)
if (mediaItems.length + files.length > 10) {
toast.error('Максимум 10 файлов')
return
}
const newItems: MediaItem[] = []
for (const file of Array.from(files)) {
// Validate file type
const isImage = file.type.startsWith('image/')
const isVideo = file.type.startsWith('video/')
if (!isImage && !isVideo) {
toast.error(`${file.name}: поддерживаются только изображения и видео`)
continue
}
// Validate file size (20MB for images, 50MB for videos)
const maxSize = isImage ? 20 * 1024 * 1024 : 50 * 1024 * 1024
if (file.size > maxSize) {
toast.error(`${file.name}: файл слишком большой. Максимум: ${isImage ? '20MB' : '50MB'}`)
continue
}
newItems.push({
file,
preview: URL.createObjectURL(file),
type: isImage ? 'photo' : 'video'
})
}
if (newItems.length > 0) {
setMediaItems(prev => [...prev, ...newItems])
}
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
// Remove single media item
const removeMediaItem = (index: number) => {
setMediaItems(prev => {
const item = prev[index]
if (item) {
URL.revokeObjectURL(item.preview)
}
return prev.filter((_, i) => i !== index)
})
}
// Clear all media
const clearAllMedia = () => {
mediaItems.forEach(item => URL.revokeObjectURL(item.preview))
setMediaItems([])
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
// Filter marathons based on search and status
const filteredMarathons = useMemo(() => {
return marathons.filter(m => {
const matchesSearch = !marathonSearch ||
m.title.toLowerCase().includes(marathonSearch.toLowerCase())
const matchesStatus = statusFilter === 'all' || m.status === statusFilter
return matchesSearch && matchesStatus
})
}, [marathons, marathonSearch, statusFilter])
const selectedMarathon = marathons.find(m => m.id === marathonId)
const handleSend = async () => {
if (!message.trim() && mediaItems.length === 0) {
toast.error('Введите сообщение или прикрепите медиа')
return
}
if (targetType === 'marathon' && !marathonId) {
toast.error('Выберите марафон')
return
}
setSending(true)
try {
const files = mediaItems.map(item => item.file)
let result
if (targetType === 'all') {
result = await adminApi.broadcastToAll(message, files.length > 0 ? files : undefined)
} else {
result = await adminApi.broadcastToMarathon(marathonId!, message, files.length > 0 ? files : undefined)
}
toast.success(`Отправлено ${result.sent_count} из ${result.total_count} сообщений`)
setMessage('')
clearAllMedia()
} catch (err) {
console.error('Failed to send broadcast:', err)
toast.error('Ошибка отправки')
} finally {
setSending(false)
}
}
const getStatusBadge = (status: string) => {
const config = {
active: { label: 'Активен', class: 'bg-green-500/20 text-green-400 border-green-500/30' },
preparing: { label: 'Подготовка', class: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' },
finished: { label: 'Завершён', class: 'bg-gray-500/20 text-gray-400 border-gray-500/30' },
}
const c = config[status as keyof typeof config] || config.finished
return (
<span className={`text-xs px-2 py-0.5 rounded border ${c.class}`}>
{c.label}
</span>
)
}
const insertTag = (openTag: string, closeTag: string) => {
const textarea = textareaRef.current
if (!textarea) return
const start = textarea.selectionStart
const end = textarea.selectionEnd
const selectedText = message.substring(start, end)
const newText = message.substring(0, start) + openTag + selectedText + closeTag + message.substring(end)
handleMessageChange(newText)
// Restore cursor position
setTimeout(() => {
textarea.focus()
const newCursorPos = start + openTag.length + selectedText.length
textarea.setSelectionRange(newCursorPos, newCursorPos)
}, 0)
}
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="flex gap-6">
{/* Left Column - Editor */}
<div className="flex-1 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-3">
<label className="block text-sm font-medium text-gray-300">
Выберите марафон
</label>
{/* Search and Filter */}
<div className="flex gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
placeholder="Поиск по названию..."
value={marathonSearch}
onChange={(e) => setMarathonSearch(e.target.value)}
className="w-full bg-dark-700/50 border border-dark-600 rounded-lg pl-9 pr-3 py-2 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
className="bg-dark-700/50 border border-dark-600 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-accent-500/50 transition-colors"
>
<option value="all">Все статусы</option>
<option value="active">Активные</option>
<option value="preparing">Подготовка</option>
<option value="finished">Завершённые</option>
</select>
</div>
{/* Marathon List */}
{loadingMarathons ? (
<div className="animate-pulse bg-dark-700 h-32 rounded-xl" />
) : (
<div className="max-h-48 overflow-y-auto rounded-xl border border-dark-600 bg-dark-800/50">
{filteredMarathons.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">
{marathons.length === 0 ? (
<div className="space-y-2">
<p>Не удалось загрузить марафоны</p>
<button
onClick={loadMarathons}
className="text-accent-400 hover:underline"
>
Повторить загрузку
</button>
</div>
) : (
'Марафоны не найдены по фильтру'
)}
</div>
) : (
filteredMarathons.map((m) => (
<button
key={m.id}
onClick={() => setMarathonId(marathonId === m.id ? null : m.id)}
className={`w-full flex items-center justify-between p-3 text-left border-b border-dark-600 last:border-b-0 transition-colors ${
marathonId === m.id
? 'bg-accent-500/20 text-white'
: 'hover:bg-dark-700/50 text-gray-300'
}`}
>
<div className="flex items-center gap-3 min-w-0">
<span className="truncate font-medium">{m.title}</span>
{getStatusBadge(m.status)}
</div>
<span className="text-xs text-gray-500 flex-shrink-0 ml-2">
{m.participants_count} уч.
</span>
</button>
))
)}
</div>
)}
{/* Selected Marathon Info */}
{selectedMarathon && (
<div className="p-3 bg-accent-500/10 border border-accent-500/30 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-accent-400">
<Trophy className="w-4 h-4" />
<span className="font-medium">{selectedMarathon.title}</span>
{getStatusBadge(selectedMarathon.status)}
</div>
<button
onClick={() => setMarathonId(null)}
className="p-1 text-gray-400 hover:text-white rounded hover:bg-dark-600/50 transition-colors"
title="Отменить выбор"
>
<X className="w-4 h-4" />
</button>
</div>
<p className="text-sm text-gray-400 mt-1">
{selectedMarathon.participants_count} участников получат сообщение
</p>
</div>
)}
</div>
)}
{/* Message with Tag Toolbar */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-300">
Сообщение
</label>
<button
onClick={() => setShowTagsHelp(!showTagsHelp)}
className="text-xs text-gray-500 hover:text-accent-400 flex items-center gap-1 transition-colors"
>
<ChevronDown className={`w-3 h-3 transition-transform ${showTagsHelp ? 'rotate-180' : ''}`} />
Справка по тегам
</button>
</div>
{/* Quick Tag Buttons */}
<div className="flex flex-wrap gap-1">
<button
onClick={() => insertTag('<b>', '</b>')}
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded font-bold transition-colors"
title="Жирный"
>
B
</button>
<button
onClick={() => insertTag('<i>', '</i>')}
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded italic transition-colors"
title="Курсив"
>
I
</button>
<button
onClick={() => insertTag('<u>', '</u>')}
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded underline transition-colors"
title="Подчёркнутый"
>
U
</button>
<button
onClick={() => insertTag('<s>', '</s>')}
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded line-through transition-colors"
title="Зачёркнутый"
>
S
</button>
<button
onClick={() => insertTag('<code>', '</code>')}
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded font-mono transition-colors"
title="Код"
>
{'</>'}
</button>
<button
onClick={() => insertTag('<pre>', '</pre>')}
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded font-mono transition-colors"
title="Блок кода"
>
PRE
</button>
<button
onClick={() => insertTag('<a href="URL">', '</a>')}
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded transition-colors"
title="Ссылка"
>
🔗
</button>
<button
onClick={() => insertTag('<tg-spoiler>', '</tg-spoiler>')}
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded transition-colors"
title="Спойлер"
>
👁
</button>
<button
onClick={() => insertTag('<blockquote>', '</blockquote>')}
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded transition-colors"
title="Цитата"
>
</button>
</div>
{/* Tags Help */}
{showTagsHelp && (
<div className="p-3 bg-dark-800 border border-dark-600 rounded-lg text-xs space-y-2">
<p className="text-gray-400 font-medium mb-2">Поддерживаемые теги Telegram:</p>
<div className="grid grid-cols-2 gap-2">
{TELEGRAM_TAGS.map((t) => (
<div key={t.tag} className="flex items-center gap-2">
<code className="text-neon-400 bg-dark-700 px-1.5 py-0.5 rounded">{t.tag}</code>
<span className="text-gray-500"> {t.description}</span>
</div>
))}
</div>
</div>
)}
<textarea
ref={textareaRef}
value={message}
onChange={(e) => handleMessageChange(e.target.value)}
onKeyDown={handleKeyDown}
rows={8}
placeholder="Введите текст сообщения..."
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 font-mono text-sm"
/>
<div className="flex items-center justify-between">
<p className="text-xs text-gray-600">
Ctrl+Z отмена Ctrl+Shift+Z повтор
</p>
<p className={`text-xs ${message.length > 2000 ? 'text-red-400' : 'text-gray-500'}`}>
{message.length} / 2000
</p>
</div>
{/* Media Attachment */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<input
type="file"
ref={fileInputRef}
onChange={handleMediaSelect}
accept="image/*,video/*"
multiple
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={mediaItems.length >= 10}
className="flex items-center gap-2 px-3 py-2 text-sm bg-dark-700 hover:bg-dark-600 text-gray-300 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Image className="w-4 h-4" />
<Film className="w-4 h-4" />
<span>Прикрепить медиа</span>
</button>
{mediaItems.length > 0 && (
<>
<span className="text-sm text-gray-500">
{mediaItems.length}/10 файлов
</span>
<button
onClick={clearAllMedia}
className="flex items-center gap-1 px-2 py-1 text-xs text-red-400 hover:text-red-300 transition-colors"
>
<Trash2 className="w-3 h-3" />
Удалить все
</button>
</>
)}
</div>
{/* Media Grid Preview */}
{mediaItems.length > 0 && (
<div className="grid grid-cols-5 gap-2">
{mediaItems.map((item, index) => (
<div
key={index}
className="relative aspect-square rounded-lg overflow-hidden border border-dark-600 bg-dark-800 group"
>
{item.type === 'photo' ? (
<img
src={item.preview}
alt={`Preview ${index + 1}`}
className="w-full h-full object-cover"
/>
) : (
<div className="relative w-full h-full">
<video
src={item.preview}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<Film className="w-6 h-6 text-white" />
</div>
</div>
)}
<button
onClick={() => removeMediaItem(index)}
className="absolute top-1 right-1 p-1 bg-red-500/80 hover:bg-red-500 text-white rounded opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3 h-3" />
</button>
<div className="absolute bottom-1 left-1 px-1.5 py-0.5 bg-black/60 rounded text-xs text-white">
{item.type === 'photo' ? 'IMG' : 'VID'}
</div>
</div>
))}
{mediaItems.length < 10 && (
<button
onClick={() => fileInputRef.current?.click()}
className="aspect-square rounded-lg border-2 border-dashed border-dark-500 hover:border-accent-500/50 flex items-center justify-center text-gray-500 hover:text-accent-400 transition-colors"
>
<Plus className="w-6 h-6" />
</button>
)}
</div>
)}
</div>
</div>
{/* Send Button */}
<NeonButton
size="lg"
color="purple"
onClick={handleSend}
disabled={sending || (!message.trim() && mediaItems.length === 0) || 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>
{/* Right Column - Preview */}
<div className="w-96 flex-shrink-0">
<div className="sticky top-6 space-y-3">
<div className="flex items-center gap-2 text-gray-400">
<Eye className="w-4 h-4" />
<span className="text-sm font-medium">Предпросмотр</span>
</div>
{/* Telegram-style Preview */}
<div className="bg-[#0e1621] rounded-2xl p-4 border border-dark-600 shadow-xl">
{/* Chat Header */}
<div className="flex items-center gap-3 pb-3 border-b border-dark-600 mb-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-accent-500 to-pink-500 flex items-center justify-center text-white font-bold">
M
</div>
<div>
<p className="text-white font-medium text-sm">Marathon Bot</p>
<p className="text-gray-500 text-xs">бот</p>
</div>
</div>
{/* Message Bubble */}
<div className="bg-[#182533] rounded-2xl rounded-tl-md overflow-hidden max-w-full">
{/* Media Preview in Telegram style */}
{mediaItems.length > 0 && (
<div className={`${mediaItems.length > 1 ? 'grid grid-cols-2 gap-0.5' : ''}`}>
{mediaItems.slice(0, 4).map((item, index) => (
<div key={index} className="relative">
{item.type === 'photo' ? (
<img
src={item.preview}
alt={`Preview ${index + 1}`}
className={`w-full object-cover ${mediaItems.length === 1 ? 'max-h-64' : 'h-24'}`}
/>
) : (
<div className="relative bg-black">
<video
src={item.preview}
className={`w-full object-cover ${mediaItems.length === 1 ? 'max-h-64' : 'h-24'}`}
/>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-8 h-8 rounded-full bg-white/30 backdrop-blur flex items-center justify-center">
<Film className="w-4 h-4 text-white" />
</div>
</div>
</div>
)}
{/* Show +N indicator on last visible item if there are more */}
{index === 3 && mediaItems.length > 4 && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
<span className="text-white text-lg font-bold">+{mediaItems.length - 4}</span>
</div>
)}
</div>
))}
</div>
)}
<div className="p-3">
{message.trim() ? (
<div
className="text-white text-sm break-words telegram-preview"
dangerouslySetInnerHTML={{ __html: telegramToHtml(message) }}
/>
) : mediaItems.length === 0 ? (
<p className="text-gray-500 text-sm italic">Введите сообщение...</p>
) : null}
<p className="text-gray-500 text-xs text-right mt-2">
{new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
</div>
{/* Info */}
<div className="mt-4 pt-3 border-t border-dark-600">
<div className="flex items-center gap-2 text-gray-500 text-xs">
<MessageSquare className="w-3 h-3" />
<span>
Так будет выглядеть сообщение в Telegram
</span>
</div>
</div>
</div>
{/* Preview Notes */}
<div className="text-xs text-gray-500 space-y-1">
<p> Спойлеры (<code className="text-gray-400">&lt;tg-spoiler&gt;</code>) показаны размытыми</p>
<p> Цитаты (<code className="text-gray-400">&lt;blockquote&gt;</code>) с вертикальной линией</p>
<p> Ссылки будут кликабельны в Telegram</p>
</div>
</div>
</div>
</div>
{/* Custom styles for Telegram preview */}
<style>{`
.telegram-preview b, .telegram-preview strong {
font-weight: 600;
}
.telegram-preview i, .telegram-preview em {
font-style: italic;
}
.telegram-preview u {
text-decoration: underline;
}
.telegram-preview s, .telegram-preview strike, .telegram-preview del {
text-decoration: line-through;
}
.telegram-preview code {
font-family: monospace;
background: rgba(255,255,255,0.1);
padding: 1px 4px;
border-radius: 4px;
font-size: 0.9em;
}
.telegram-preview pre {
font-family: monospace;
background: rgba(0,0,0,0.3);
padding: 8px 12px;
border-radius: 8px;
overflow-x: auto;
margin: 4px 0;
font-size: 0.85em;
}
.telegram-preview a {
color: #6ab2f2;
text-decoration: none;
}
.telegram-preview a:hover {
text-decoration: underline;
}
.telegram-preview .tg-spoiler {
background: #333;
color: transparent;
border-radius: 4px;
padding: 0 4px;
cursor: pointer;
transition: all 0.2s;
}
.telegram-preview .tg-spoiler:hover {
color: white;
background: transparent;
}
.telegram-preview blockquote {
border-left: 3px solid #6ab2f2;
padding-left: 12px;
margin: 8px 0;
color: #aaa;
}
`}</style>
</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>
)
}

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