Compare commits
22 Commits
develop
...
7a3576aec0
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a3576aec0 | |||
| d295ff2aff | |||
| 1e751f7af3 | |||
| 89dbe2c018 | |||
| 1cedfeb3ee | |||
| 1e723e7bcd | |||
| a513dc2207 | |||
| 6bc35fc0bb | |||
| d3adf07c3f | |||
| 921917a319 | |||
| 9d2dba87b8 | |||
| 95e2a77335 | |||
| 6c824712c9 | |||
| 5c073705d8 | |||
| 243abe55b5 | |||
| c645171671 | |||
| 07745ea4ed | |||
| 22385e8742 | |||
| a77a757317 | |||
| 2d281d1c8c | |||
| 13f484e726 | |||
| ebaf6d39ea |
@@ -32,3 +32,5 @@ PUBLIC_URL=https://your-domain.com
|
|||||||
|
|
||||||
# Frontend (for build)
|
# Frontend (for build)
|
||||||
VITE_API_URL=/api/v1
|
VITE_API_URL=/api/v1
|
||||||
|
|
||||||
|
RATE_LIMIT_ENABLED=false
|
||||||
348
REDESIGN_PLAN.md
348
REDESIGN_PLAN.md
@@ -22,353 +22,7 @@ Success: #22c55e
|
|||||||
Error: #ef4444
|
Error: #ef4444
|
||||||
Text: #e2e8f0
|
Text: #e2e8f0
|
||||||
Text Muted: #64748b
|
Text Muted: #64748b
|
||||||
```
|
```А ты
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Фаза 1: Базовая инфраструктура
|
|
||||||
|
|
||||||
### 1.1 Обновление Tailwind Config
|
|
||||||
- [ ] Новая цветовая палитра (neon colors)
|
|
||||||
- [ ] Кастомные анимации:
|
|
||||||
- `glitch` - glitch эффект для текста
|
|
||||||
- `glow-pulse` - пульсация свечения
|
|
||||||
- `float` - плавное парение
|
|
||||||
- `slide-in-left/right/up/down` - слайды
|
|
||||||
- `scale-in` - появление с масштабом
|
|
||||||
- `shimmer` - блик на элементах
|
|
||||||
- [ ] Кастомные backdrop-blur классы
|
|
||||||
- [ ] Градиентные утилиты
|
|
||||||
|
|
||||||
### 1.2 Глобальные стили (index.css)
|
|
||||||
- [ ] CSS переменные для цветов
|
|
||||||
- [ ] Glitch keyframes анимация
|
|
||||||
- [ ] Noise/grain overlay
|
|
||||||
- [ ] Glow эффекты (box-shadow неон)
|
|
||||||
- [ ] Custom scrollbar (неоновый)
|
|
||||||
- [ ] Selection стили (выделение текста)
|
|
||||||
|
|
||||||
### 1.3 Новые UI компоненты
|
|
||||||
- [ ] `GlitchText` - текст с glitch эффектом
|
|
||||||
- [ ] `NeonButton` - кнопка с неоновым свечением
|
|
||||||
- [ ] `GlassCard` - карточка с glassmorphism
|
|
||||||
- [ ] `AnimatedCounter` - анимированные числа
|
|
||||||
- [ ] `ProgressBar` - неоновый прогресс бар
|
|
||||||
- [ ] `Badge` - бейджи со свечением
|
|
||||||
- [ ] `Skeleton` - скелетоны загрузки
|
|
||||||
- [ ] `Tooltip` - тултипы
|
|
||||||
- [ ] `Tabs` - табы с анимацией
|
|
||||||
- [ ] `Modal` - переработанная модалка
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Фаза 2: Layout и навигация
|
|
||||||
|
|
||||||
### 2.1 Новый Header
|
|
||||||
- [ ] Sticky header с blur при скролле
|
|
||||||
- [ ] Логотип с glitch эффектом при hover
|
|
||||||
- [ ] Анимированная навигация (underline slide)
|
|
||||||
- [ ] Notification bell с badge
|
|
||||||
- [ ] User dropdown с аватаром
|
|
||||||
- [ ] Mobile hamburger menu с slide-in
|
|
||||||
|
|
||||||
### 2.2 Sidebar (новый компонент)
|
|
||||||
- [ ] Collapsible sidebar для desktop
|
|
||||||
- [ ] Иконки с tooltip
|
|
||||||
- [ ] Active state с неоновой подсветкой
|
|
||||||
- [ ] Quick stats внизу
|
|
||||||
|
|
||||||
### 2.3 Footer
|
|
||||||
- [ ] Минималистичный footer
|
|
||||||
- [ ] Social links
|
|
||||||
- [ ] Version info
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Фаза 3: Страницы
|
|
||||||
|
|
||||||
### 3.1 HomePage (полный редизайн)
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────┐
|
|
||||||
│ HERO SECTION │
|
|
||||||
│ ┌─────────────────────────────────────┐ │
|
|
||||||
│ │ Animated background (particles/ │ │
|
|
||||||
│ │ geometric shapes) │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ GAME <glitch>MARATHON</glitch> │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ Tagline with typing effect │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ [Start Playing] [Watch Demo] │ │
|
|
||||||
│ └─────────────────────────────────────┘ │
|
|
||||||
├─────────────────────────────────────────────┤
|
|
||||||
│ FEATURES SECTION (3 glass cards) │
|
|
||||||
│ ┌───────┐ ┌───────┐ ┌───────┐ │
|
|
||||||
│ │ Icon │ │ Icon │ │ Icon │ hover: │
|
|
||||||
│ │ Title │ │ Title │ │ Title │ lift + │
|
|
||||||
│ │ Desc │ │ Desc │ │ Desc │ glow │
|
|
||||||
│ └───────┘ └───────┘ └───────┘ │
|
|
||||||
├─────────────────────────────────────────────┤
|
|
||||||
│ HOW IT WORKS (timeline style) │
|
|
||||||
│ ○───────○───────○───────○ │
|
|
||||||
│ 1 2 3 4 │
|
|
||||||
│ Create Add Spin Win │
|
|
||||||
├─────────────────────────────────────────────┤
|
|
||||||
│ LIVE STATS (animated counters) │
|
|
||||||
│ [ 1,234 Marathons ] [ 5,678 Challenges ] │
|
|
||||||
├─────────────────────────────────────────────┤
|
|
||||||
│ CTA SECTION │
|
|
||||||
│ Ready to compete? [Join Now] │
|
|
||||||
└─────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 Login/Register Pages
|
|
||||||
- [ ] Centered card с glassmorphism
|
|
||||||
- [ ] Animated background (subtle)
|
|
||||||
- [ ] Form inputs с glow при focus
|
|
||||||
- [ ] Password strength indicator
|
|
||||||
- [ ] Social login buttons (future)
|
|
||||||
- [ ] Smooth transitions между login/register
|
|
||||||
|
|
||||||
### 3.3 MarathonsPage (Dashboard)
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────┐
|
|
||||||
│ Header: "My Marathons" + Create button │
|
|
||||||
├─────────────────────────────────────────────┤
|
|
||||||
│ Quick Stats Bar │
|
|
||||||
│ [Active: 2] [Completed: 5] [Total Wins: 3]│
|
|
||||||
├─────────────────────────────────────────────┤
|
|
||||||
│ Filters/Tabs: All | Active | Completed │
|
|
||||||
├─────────────────────────────────────────────┤
|
|
||||||
│ Marathon Cards Grid (2-3 columns) │
|
|
||||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
|
||||||
│ │ Cover image/ │ │ │ │
|
|
||||||
│ │ gradient │ │ │ │
|
|
||||||
│ │ ──────────── │ │ │ │
|
|
||||||
│ │ Title │ │ │ │
|
|
||||||
│ │ Status badge │ │ │ │
|
|
||||||
│ │ Participants │ │ │ │
|
|
||||||
│ │ Progress bar │ │ │ │
|
|
||||||
│ └──────────────────┘ └──────────────────┘ │
|
|
||||||
├─────────────────────────────────────────────┤
|
|
||||||
│ Join by Code (expandable section) │
|
|
||||||
└─────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.4 MarathonPage (Detail)
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────┐
|
|
||||||
│ Hero Banner │
|
|
||||||
│ ┌─────────────────────────────────────┐ │
|
|
||||||
│ │ Background gradient + pattern │ │
|
|
||||||
│ │ Marathon Title (large) │ │
|
|
||||||
│ │ Status | Dates | Participants │ │
|
|
||||||
│ │ [Play] [Leaderboard] [Settings] │ │
|
|
||||||
│ └─────────────────────────────────────┘ │
|
|
||||||
├─────────────────────────────────────────────┤
|
|
||||||
│ Event Banner (if active) - animated │
|
|
||||||
├────────────────────┬────────────────────────┤
|
|
||||||
│ Main Content │ Sidebar │
|
|
||||||
│ ┌──────────────┐ │ ┌──────────────────┐ │
|
|
||||||
│ │ Your Stats │ │ │ Activity Feed │ │
|
|
||||||
│ │ Points/Streak│ │ │ (scrollable) │ │
|
|
||||||
│ └──────────────┘ │ │ │ │
|
|
||||||
│ ┌──────────────┐ │ │ │ │
|
|
||||||
│ │ Quick Actions│ │ │ │ │
|
|
||||||
│ └──────────────┘ │ │ │ │
|
|
||||||
│ ┌──────────────┐ │ │ │ │
|
|
||||||
│ │ Games List │ │ │ │ │
|
|
||||||
│ │ (collapsible)│ │ │ │ │
|
|
||||||
│ └──────────────┘ │ └──────────────────┘ │
|
|
||||||
└────────────────────┴────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.5 PlayPage (Game Screen) - ГЛАВНАЯ СТРАНИЦА
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────┐
|
|
||||||
│ Top Bar: Points | Streak | Event Timer │
|
|
||||||
├─────────────────────────────────────────────┤
|
|
||||||
│ ┌─────────────────────────────────────┐ │
|
|
||||||
│ │ SPIN WHEEL │ │
|
|
||||||
│ │ (redesigned, neon style) │ │
|
|
||||||
│ │ ┌─────────┐ │ │
|
|
||||||
│ │ │ WHEEL │ │ │
|
|
||||||
│ │ │ │ │ │
|
|
||||||
│ │ └─────────┘ │ │
|
|
||||||
│ │ [SPIN BUTTON] │ │
|
|
||||||
│ └─────────────────────────────────────┘ │
|
|
||||||
├─────────────────────────────────────────────┤
|
|
||||||
│ Active Challenge Card (если есть) │
|
|
||||||
│ ┌─────────────────────────────────────┐ │
|
|
||||||
│ │ Game: [Title] | Difficulty: [★★★] │ │
|
|
||||||
│ │ ─────────────────────────────────── │ │
|
|
||||||
│ │ Challenge Title │ │
|
|
||||||
│ │ Description... │ │
|
|
||||||
│ │ ─────────────────────────────────── │ │
|
|
||||||
│ │ Points: 100 | Time: ~2h │ │
|
|
||||||
│ │ ─────────────────────────────────── │ │
|
|
||||||
│ │ Proof Upload Area │ │
|
|
||||||
│ │ [File] [URL] [Comment] │ │
|
|
||||||
│ │ ─────────────────────────────────── │ │
|
|
||||||
│ │ [Complete ✓] [Skip ✗] │ │
|
|
||||||
│ └─────────────────────────────────────┘ │
|
|
||||||
├─────────────────────────────────────────────┤
|
|
||||||
│ Mini Leaderboard (top 3 + you) │
|
|
||||||
└─────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.6 LeaderboardPage
|
|
||||||
- [ ] Animated podium для top 3
|
|
||||||
- [ ] Table с hover эффектами
|
|
||||||
- [ ] Highlight для текущего пользователя
|
|
||||||
- [ ] Streak fire animation
|
|
||||||
- [ ] Sorting/filtering
|
|
||||||
|
|
||||||
### 3.7 ProfilePage
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────┐
|
|
||||||
│ Profile Header │
|
|
||||||
│ ┌─────────────────────────────────────┐ │
|
|
||||||
│ │ Avatar (large, glow border) │ │
|
|
||||||
│ │ Nickname [Edit] │ │
|
|
||||||
│ │ Member since: Date │ │
|
|
||||||
│ └─────────────────────────────────────┘ │
|
|
||||||
├─────────────────────────────────────────────┤
|
|
||||||
│ Stats Cards (animated counters) │
|
|
||||||
│ [Marathons] [Wins] [Challenges] [Points] │
|
|
||||||
├─────────────────────────────────────────────┤
|
|
||||||
│ Achievements Section (future) │
|
|
||||||
├─────────────────────────────────────────────┤
|
|
||||||
│ Telegram Connection Card │
|
|
||||||
├─────────────────────────────────────────────┤
|
|
||||||
│ Security Section (password change) │
|
|
||||||
└─────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.8 LobbyPage
|
|
||||||
- [ ] Step-by-step wizard UI
|
|
||||||
- [ ] Game cards grid с preview
|
|
||||||
- [ ] Challenge preview с difficulty badges
|
|
||||||
- [ ] AI generation progress animation
|
|
||||||
- [ ] Launch countdown
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Фаза 4: Специальные компоненты
|
|
||||||
|
|
||||||
### 4.1 SpinWheel (полный редизайн)
|
|
||||||
- [ ] 3D perspective эффект
|
|
||||||
- [ ] Неоновые сегменты с названиями игр
|
|
||||||
- [ ] Particle effects при кручении
|
|
||||||
- [ ] Glow trail за указателем
|
|
||||||
- [ ] Sound effects (optional)
|
|
||||||
- [ ] Confetti при выигрыше
|
|
||||||
|
|
||||||
### 4.2 EventBanner (переработка)
|
|
||||||
- [ ] Pulsating glow border
|
|
||||||
- [ ] Countdown timer с flip animation
|
|
||||||
- [ ] Event-specific icons/colors
|
|
||||||
- [ ] Dismiss animation
|
|
||||||
|
|
||||||
### 4.3 ActivityFeed (переработка)
|
|
||||||
- [ ] Timeline style
|
|
||||||
- [ ] Avatar circles
|
|
||||||
- [ ] Type-specific icons
|
|
||||||
- [ ] Hover для деталей
|
|
||||||
- [ ] New items animation (slide-in)
|
|
||||||
|
|
||||||
### 4.4 Challenge Cards
|
|
||||||
- [ ] Difficulty stars/badges
|
|
||||||
- [ ] Progress indicator
|
|
||||||
- [ ] Expandable details
|
|
||||||
- [ ] Proof preview thumbnail
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Фаза 5: Анимации и эффекты
|
|
||||||
|
|
||||||
### 5.1 Page Transitions
|
|
||||||
- [ ] Framer Motion page transitions
|
|
||||||
- [ ] Fade + slide between routes
|
|
||||||
- [ ] Loading skeleton screens
|
|
||||||
|
|
||||||
### 5.2 Micro-interactions
|
|
||||||
- [ ] Button press effects
|
|
||||||
- [ ] Input focus glow
|
|
||||||
- [ ] Success checkmark animation
|
|
||||||
- [ ] Error shake animation
|
|
||||||
- [ ] Loading spinners (custom)
|
|
||||||
|
|
||||||
### 5.3 Background Effects
|
|
||||||
- [ ] Animated gradient mesh
|
|
||||||
- [ ] Floating particles (optional)
|
|
||||||
- [ ] Grid pattern overlay
|
|
||||||
- [ ] Noise texture
|
|
||||||
|
|
||||||
### 5.4 Special Effects
|
|
||||||
- [ ] Glitch text на заголовках
|
|
||||||
- [ ] Neon glow на важных элементах
|
|
||||||
- [ ] Shimmer effect на loading
|
|
||||||
- [ ] Confetti на achievements
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Фаза 6: Responsive и Polish
|
|
||||||
|
|
||||||
### 6.1 Mobile Optimization
|
|
||||||
- [ ] Touch-friendly targets
|
|
||||||
- [ ] Swipe gestures
|
|
||||||
- [ ] Bottom navigation (mobile)
|
|
||||||
- [ ] Collapsible sections
|
|
||||||
|
|
||||||
### 6.2 Accessibility
|
|
||||||
- [ ] Keyboard navigation
|
|
||||||
- [ ] Focus indicators
|
|
||||||
- [ ] Screen reader support
|
|
||||||
- [ ] Reduced motion option
|
|
||||||
|
|
||||||
### 6.3 Performance
|
|
||||||
- [ ] Lazy loading images
|
|
||||||
- [ ] Code splitting
|
|
||||||
- [ ] Animation optimization
|
|
||||||
- [ ] Bundle size check
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Порядок реализации
|
|
||||||
|
|
||||||
### Sprint 1: Фундамент (2-3 дня)
|
|
||||||
1. Tailwind config + colors
|
|
||||||
2. Global CSS + animations
|
|
||||||
3. Base UI components (Button, Card, Input)
|
|
||||||
4. GlitchText component
|
|
||||||
5. Updated Layout/Header
|
|
||||||
|
|
||||||
### Sprint 2: Core Pages (3-4 дня)
|
|
||||||
1. HomePage с hero
|
|
||||||
2. Login/Register
|
|
||||||
3. MarathonsPage dashboard
|
|
||||||
4. Profile page
|
|
||||||
|
|
||||||
### Sprint 3: Game Flow (3-4 дня)
|
|
||||||
1. MarathonPage detail
|
|
||||||
2. SpinWheel redesign
|
|
||||||
3. PlayPage
|
|
||||||
4. LeaderboardPage
|
|
||||||
|
|
||||||
### Sprint 4: Polish (2-3 дня)
|
|
||||||
1. LobbyPage
|
|
||||||
2. Event components
|
|
||||||
3. Activity feed
|
|
||||||
4. Animations & transitions
|
|
||||||
|
|
||||||
### Sprint 5: Finalization (1-2 дня)
|
|
||||||
1. Mobile testing
|
|
||||||
2. Performance optimization
|
|
||||||
3. Bug fixes
|
|
||||||
4. Final polish
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Референсы для вдохновления
|
## Референсы для вдохновления
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Sequence, Union
|
|||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '001_add_roles'
|
revision: str = '001_add_roles'
|
||||||
@@ -17,17 +18,35 @@ branch_labels: Union[str, Sequence[str], None] = None
|
|||||||
depends_on: Union[str, Sequence[str], None] = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def constraint_exists(table_name: str, constraint_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
fks = inspector.get_foreign_keys(table_name)
|
||||||
|
return any(fk['name'] == constraint_name for fk in fks)
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# Add role column to users table
|
# Add role column to users table
|
||||||
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
|
# 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
|
# 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
|
# Update existing participants: set role='organizer' for marathon creators
|
||||||
|
# This is idempotent - running multiple times is safe
|
||||||
op.execute("""
|
op.execute("""
|
||||||
UPDATE participants p
|
UPDATE participants p
|
||||||
SET role = 'organizer'
|
SET role = 'organizer'
|
||||||
@@ -36,37 +55,48 @@ def upgrade() -> None:
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
# Add status column to games table
|
# 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
|
# 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
|
# Add approved_by_id column to games table
|
||||||
op.add_column('games', sa.Column('approved_by_id', sa.Integer(), nullable=True))
|
if not column_exists('games', 'approved_by_id'):
|
||||||
op.create_foreign_key(
|
op.add_column('games', sa.Column('approved_by_id', sa.Integer(), nullable=True))
|
||||||
'fk_games_approved_by_id',
|
if not constraint_exists('games', 'fk_games_approved_by_id'):
|
||||||
'games', 'users',
|
op.create_foreign_key(
|
||||||
['approved_by_id'], ['id'],
|
'fk_games_approved_by_id',
|
||||||
ondelete='SET NULL'
|
'games', 'users',
|
||||||
)
|
['approved_by_id'], ['id'],
|
||||||
|
ondelete='SET NULL'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
# Remove approved_by_id from games
|
# Remove approved_by_id from games
|
||||||
op.drop_constraint('fk_games_approved_by_id', 'games', type_='foreignkey')
|
if constraint_exists('games', 'fk_games_approved_by_id'):
|
||||||
op.drop_column('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
|
# 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
|
# 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
|
# 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
|
# Remove role from participants
|
||||||
op.drop_column('participants', 'role')
|
if column_exists('participants', 'role'):
|
||||||
|
op.drop_column('participants', 'role')
|
||||||
|
|
||||||
# Remove role from users
|
# Remove role from users
|
||||||
op.drop_column('users', 'role')
|
if column_exists('users', 'role'):
|
||||||
|
op.drop_column('users', 'role')
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Sequence, Union
|
|||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '002_marathon_settings'
|
revision: str = '002_marathon_settings'
|
||||||
@@ -17,16 +18,27 @@ branch_labels: Union[str, Sequence[str], None] = None
|
|||||||
depends_on: Union[str, Sequence[str], None] = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# Add is_public column to marathons table (default False = private)
|
# Add is_public column to marathons table (default False = private)
|
||||||
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
|
# Add game_proposal_mode column to marathons table
|
||||||
# 'all_participants' - anyone can propose games (with moderation)
|
# 'all_participants' - anyone can propose games (with moderation)
|
||||||
# 'organizer_only' - only organizers can add games
|
# 'organizer_only' - only organizers can add games
|
||||||
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:
|
def downgrade() -> None:
|
||||||
op.drop_column('marathons', 'game_proposal_mode')
|
if column_exists('marathons', 'game_proposal_mode'):
|
||||||
op.drop_column('marathons', 'is_public')
|
op.drop_column('marathons', 'game_proposal_mode')
|
||||||
|
if column_exists('marathons', 'is_public'):
|
||||||
|
op.drop_column('marathons', 'is_public')
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ def upgrade() -> None:
|
|||||||
|
|
||||||
# Insert admin user (ignore if already exists)
|
# Insert admin user (ignore if already exists)
|
||||||
op.execute(f"""
|
op.execute(f"""
|
||||||
INSERT INTO users (login, password_hash, nickname, role, created_at)
|
INSERT INTO users (login, password_hash, nickname, role, is_banned, created_at)
|
||||||
VALUES ('admin', '{password_hash}', 'Admin', 'admin', NOW())
|
VALUES ('admin', '{password_hash}', 'Admin', 'admin', false, NOW())
|
||||||
ON CONFLICT (login) DO UPDATE SET
|
ON CONFLICT (login) DO UPDATE SET
|
||||||
password_hash = '{password_hash}',
|
password_hash = '{password_hash}',
|
||||||
role = 'admin'
|
role = 'admin'
|
||||||
|
|||||||
@@ -17,15 +17,17 @@ depends_on = None
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# Update event type from 'rematch' to 'game_choice' in events table
|
# Update event type from 'rematch' to 'game_choice' in events table
|
||||||
|
# These UPDATE statements are idempotent - safe to run multiple times
|
||||||
op.execute("UPDATE events SET type = 'game_choice' WHERE type = 'rematch'")
|
op.execute("UPDATE events SET type = 'game_choice' WHERE type = 'rematch'")
|
||||||
|
|
||||||
# Update event_type in assignments table
|
# Update event_type in assignments table
|
||||||
op.execute("UPDATE assignments SET event_type = 'game_choice' WHERE event_type = 'rematch'")
|
op.execute("UPDATE assignments SET event_type = 'game_choice' WHERE event_type = 'rematch'")
|
||||||
|
|
||||||
# Update activity data that references rematch event
|
# Update activity data that references rematch event
|
||||||
|
# Cast JSON to JSONB, apply jsonb_set, then cast back to JSON
|
||||||
op.execute("""
|
op.execute("""
|
||||||
UPDATE activities
|
UPDATE activities
|
||||||
SET data = jsonb_set(data, '{event_type}', '"game_choice"')
|
SET data = jsonb_set(data::jsonb, '{event_type}', '"game_choice"')::json
|
||||||
WHERE data->>'event_type' = 'rematch'
|
WHERE data->>'event_type' = 'rematch'
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -36,6 +38,6 @@ def downgrade() -> None:
|
|||||||
op.execute("UPDATE assignments SET event_type = 'rematch' WHERE event_type = 'game_choice'")
|
op.execute("UPDATE assignments SET event_type = 'rematch' WHERE event_type = 'game_choice'")
|
||||||
op.execute("""
|
op.execute("""
|
||||||
UPDATE activities
|
UPDATE activities
|
||||||
SET data = jsonb_set(data, '{event_type}', '"rematch"')
|
SET data = jsonb_set(data::jsonb, '{event_type}', '"rematch"')::json
|
||||||
WHERE data->>'event_type' = 'game_choice'
|
WHERE data->>'event_type' = 'game_choice'
|
||||||
""")
|
""")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Sequence, Union
|
|||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
@@ -18,13 +19,26 @@ branch_labels: Union[str, Sequence[str], None] = None
|
|||||||
depends_on: Union[str, Sequence[str], None] = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.add_column('users', sa.Column('telegram_first_name', sa.String(100), nullable=True))
|
if not column_exists('users', 'telegram_first_name'):
|
||||||
op.add_column('users', sa.Column('telegram_last_name', sa.String(100), nullable=True))
|
op.add_column('users', sa.Column('telegram_first_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_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:
|
def downgrade() -> None:
|
||||||
op.drop_column('users', 'telegram_avatar_url')
|
if column_exists('users', 'telegram_avatar_url'):
|
||||||
op.drop_column('users', 'telegram_last_name')
|
op.drop_column('users', 'telegram_avatar_url')
|
||||||
op.drop_column('users', 'telegram_first_name')
|
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')
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Sequence, Union
|
|||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
@@ -18,11 +19,22 @@ branch_labels: Union[str, Sequence[str], None] = None
|
|||||||
depends_on: Union[str, Sequence[str], None] = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.add_column('challenges', sa.Column('proposed_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True))
|
if not column_exists('challenges', 'proposed_by_id'):
|
||||||
op.add_column('challenges', sa.Column('status', sa.String(20), server_default='approved', nullable=False))
|
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:
|
def downgrade() -> None:
|
||||||
op.drop_column('challenges', 'status')
|
if column_exists('challenges', 'status'):
|
||||||
op.drop_column('challenges', 'proposed_by_id')
|
op.drop_column('challenges', 'status')
|
||||||
|
if column_exists('challenges', 'proposed_by_id'):
|
||||||
|
op.drop_column('challenges', 'proposed_by_id')
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Sequence, Union
|
|||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
@@ -18,15 +19,30 @@ branch_labels: Union[str, Sequence[str], None] = None
|
|||||||
depends_on: Union[str, Sequence[str], None] = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.add_column('users', sa.Column('is_banned', sa.Boolean(), server_default='false', nullable=False))
|
if not column_exists('users', 'is_banned'):
|
||||||
op.add_column('users', sa.Column('banned_at', sa.DateTime(), nullable=True))
|
op.add_column('users', sa.Column('is_banned', sa.Boolean(), server_default='false', nullable=False))
|
||||||
op.add_column('users', sa.Column('banned_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True))
|
if not column_exists('users', 'banned_at'):
|
||||||
op.add_column('users', sa.Column('ban_reason', sa.String(500), nullable=True))
|
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:
|
def downgrade() -> None:
|
||||||
op.drop_column('users', 'ban_reason')
|
if column_exists('users', 'ban_reason'):
|
||||||
op.drop_column('users', 'banned_by_id')
|
op.drop_column('users', 'ban_reason')
|
||||||
op.drop_column('users', 'banned_at')
|
if column_exists('users', 'banned_by_id'):
|
||||||
op.drop_column('users', 'is_banned')
|
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')
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Sequence, Union
|
|||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
@@ -18,15 +19,29 @@ branch_labels: Union[str, Sequence[str], None] = None
|
|||||||
depends_on: Union[str, Sequence[str], None] = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def 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:
|
def upgrade() -> None:
|
||||||
# Make admin_id nullable for system actions (like auto-unban)
|
# Make admin_id nullable for system actions (like auto-unban)
|
||||||
op.alter_column('admin_logs', 'admin_id',
|
# Only alter if currently not nullable
|
||||||
existing_type=sa.Integer(),
|
if not is_column_nullable('admin_logs', 'admin_id'):
|
||||||
nullable=True)
|
op.alter_column('admin_logs', 'admin_id',
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
nullable=True)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
# Revert to not nullable (will fail if there are NULL values)
|
# Revert to not nullable (will fail if there are NULL values)
|
||||||
op.alter_column('admin_logs', 'admin_id',
|
if is_column_nullable('admin_logs', 'admin_id'):
|
||||||
existing_type=sa.Integer(),
|
op.alter_column('admin_logs', 'admin_id',
|
||||||
nullable=False)
|
existing_type=sa.Integer(),
|
||||||
|
nullable=False)
|
||||||
|
|||||||
346
backend/alembic/versions/018_seed_static_content.py
Normal file
346
backend/alembic/versions/018_seed_static_content.py
Normal 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)})")
|
||||||
43
backend/alembic/versions/019_add_marathon_cover.py
Normal file
43
backend/alembic/versions/019_add_marathon_cover.py
Normal 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')
|
||||||
156
backend/alembic/versions/020_add_game_types.py
Normal file
156
backend/alembic/versions/020_add_game_types.py
Normal 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')
|
||||||
100
backend/alembic/versions/021_add_bonus_disputes.py
Normal file
100
backend/alembic/versions/021_add_bonus_disputes.py
Normal 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')
|
||||||
@@ -5,13 +5,17 @@ from sqlalchemy.orm import selectinload
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
|
from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
|
||||||
from app.models import User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent
|
from app.models import (
|
||||||
|
User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent,
|
||||||
|
Dispute, DisputeStatus, Assignment, AssignmentStatus, Challenge, BonusAssignment, BonusAssignmentStatus
|
||||||
|
)
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
UserPublic, MessageResponse,
|
UserPublic, MessageResponse,
|
||||||
AdminUserResponse, BanUserRequest, AdminLogResponse, AdminLogsListResponse,
|
AdminUserResponse, BanUserRequest, AdminResetPasswordRequest, AdminLogResponse, AdminLogsListResponse,
|
||||||
BroadcastRequest, BroadcastResponse, StaticContentResponse, StaticContentUpdate,
|
BroadcastRequest, BroadcastResponse, StaticContentResponse, StaticContentUpdate,
|
||||||
StaticContentCreate, DashboardStats
|
StaticContentCreate, DashboardStats
|
||||||
)
|
)
|
||||||
|
from app.core.security import get_password_hash
|
||||||
from app.services.telegram_notifier import telegram_notifier
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
from app.core.rate_limit import limiter
|
from app.core.rate_limit import limiter
|
||||||
|
|
||||||
@@ -431,6 +435,66 @@ async def unban_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Reset Password ============
|
||||||
|
@router.post("/users/{user_id}/reset-password", response_model=AdminUserResponse)
|
||||||
|
async def reset_user_password(
|
||||||
|
request: Request,
|
||||||
|
user_id: int,
|
||||||
|
data: AdminResetPasswordRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Reset user password. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Hash and save new password
|
||||||
|
user.password_hash = get_password_hash(data.new_password)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.USER_PASSWORD_RESET.value,
|
||||||
|
"user", user_id,
|
||||||
|
{"nickname": user.nickname},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify user via Telegram if linked
|
||||||
|
if user.telegram_id:
|
||||||
|
await telegram_notifier.send_message(
|
||||||
|
user.telegram_id,
|
||||||
|
"🔐 <b>Ваш пароль был сброшен</b>\n\n"
|
||||||
|
"Администратор установил вам новый пароль. "
|
||||||
|
"Если это были не вы, свяжитесь с поддержкой."
|
||||||
|
)
|
||||||
|
|
||||||
|
marathons_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AdminUserResponse(
|
||||||
|
id=user.id,
|
||||||
|
login=user.login,
|
||||||
|
nickname=user.nickname,
|
||||||
|
role=user.role,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
telegram_id=user.telegram_id,
|
||||||
|
telegram_username=user.telegram_username,
|
||||||
|
marathons_count=marathons_count,
|
||||||
|
created_at=user.created_at.isoformat(),
|
||||||
|
is_banned=user.is_banned,
|
||||||
|
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
||||||
|
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
||||||
|
ban_reason=user.ban_reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============ Force Finish Marathon ============
|
# ============ Force Finish Marathon ============
|
||||||
@router.post("/marathons/{marathon_id}/force-finish", response_model=MessageResponse)
|
@router.post("/marathons/{marathon_id}/force-finish", response_model=MessageResponse)
|
||||||
async def force_finish_marathon(
|
async def force_finish_marathon(
|
||||||
@@ -697,6 +761,37 @@ async def create_content(
|
|||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/content/{key}", response_model=MessageResponse)
|
||||||
|
async def delete_content(
|
||||||
|
key: str,
|
||||||
|
request: Request,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Delete static content. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(StaticContent).where(StaticContent.key == key)
|
||||||
|
)
|
||||||
|
content = result.scalar_one_or_none()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=404, detail="Content not found")
|
||||||
|
|
||||||
|
await db.delete(content)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.CONTENT_UPDATE.value,
|
||||||
|
"static_content", content.id,
|
||||||
|
{"action": "delete", "key": key},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": f"Content '{key}' deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
# ============ Dashboard ============
|
# ============ Dashboard ============
|
||||||
@router.get("/dashboard", response_model=DashboardStats)
|
@router.get("/dashboard", response_model=DashboardStats)
|
||||||
async def get_dashboard(current_user: CurrentUser, db: DbSession):
|
async def get_dashboard(current_user: CurrentUser, db: DbSession):
|
||||||
@@ -745,3 +840,273 @@ async def get_dashboard(current_user: CurrentUser, db: DbSession):
|
|||||||
for log in recent_logs
|
for log in recent_logs
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Disputes Management ============
|
||||||
|
|
||||||
|
class AdminDisputeResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
assignment_id: int | None
|
||||||
|
bonus_assignment_id: int | None
|
||||||
|
marathon_id: int
|
||||||
|
marathon_title: str
|
||||||
|
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("/disputes", response_model=list[AdminDisputeResponse])
|
||||||
|
async def list_disputes(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
status: str = Query("pending", pattern="^(open|pending|all)$"),
|
||||||
|
):
|
||||||
|
"""List all disputes. Admin only.
|
||||||
|
|
||||||
|
Status filter:
|
||||||
|
- pending: disputes waiting for admin decision (default)
|
||||||
|
- open: disputes still in voting phase
|
||||||
|
- all: all disputes
|
||||||
|
"""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
DISPUTE_WINDOW_HOURS = 24
|
||||||
|
|
||||||
|
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(Challenge.game),
|
||||||
|
selectinload(Dispute.assignment).selectinload(Assignment.game), # For playthrough
|
||||||
|
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 == "pending":
|
||||||
|
# Disputes waiting for admin decision
|
||||||
|
query = query.where(Dispute.status == DisputeStatus.PENDING_ADMIN.value)
|
||||||
|
elif status == "open":
|
||||||
|
# Disputes still in voting phase
|
||||||
|
query = query.where(Dispute.status == DisputeStatus.OPEN.value)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
disputes = result.scalars().all()
|
||||||
|
|
||||||
|
response = []
|
||||||
|
for dispute in disputes:
|
||||||
|
# Get info based on dispute type
|
||||||
|
if dispute.bonus_assignment_id:
|
||||||
|
bonus = dispute.bonus_assignment
|
||||||
|
main_assignment = bonus.main_assignment
|
||||||
|
participant = main_assignment.participant
|
||||||
|
challenge_title = f"Бонус: {bonus.challenge.title}"
|
||||||
|
marathon_id = main_assignment.game.marathon_id
|
||||||
|
|
||||||
|
# Get marathon title
|
||||||
|
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = marathon_result.scalar_one_or_none()
|
||||||
|
marathon_title = marathon.title if marathon else "Unknown"
|
||||||
|
else:
|
||||||
|
assignment = dispute.assignment
|
||||||
|
participant = assignment.participant
|
||||||
|
if assignment.is_playthrough:
|
||||||
|
challenge_title = f"Прохождение: {assignment.game.title}"
|
||||||
|
marathon_id = assignment.game.marathon_id
|
||||||
|
else:
|
||||||
|
challenge_title = assignment.challenge.title
|
||||||
|
marathon_id = assignment.challenge.game.marathon_id
|
||||||
|
|
||||||
|
# Get marathon title
|
||||||
|
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = marathon_result.scalar_one_or_none()
|
||||||
|
marathon_title = marathon.title if marathon else "Unknown"
|
||||||
|
|
||||||
|
# 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(AdminDisputeResponse(
|
||||||
|
id=dispute.id,
|
||||||
|
assignment_id=dispute.assignment_id,
|
||||||
|
bonus_assignment_id=dispute.bonus_assignment_id,
|
||||||
|
marathon_id=marathon_id,
|
||||||
|
marathon_title=marathon_title,
|
||||||
|
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("/disputes/{dispute_id}/resolve", response_model=MessageResponse)
|
||||||
|
async def resolve_dispute(
|
||||||
|
request: Request,
|
||||||
|
dispute_id: int,
|
||||||
|
data: ResolveDisputeRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Manually resolve a dispute. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
# 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")
|
||||||
|
|
||||||
|
# Allow resolving disputes that are either open or pending admin decision
|
||||||
|
if dispute.status not in [DisputeStatus.OPEN.value, DisputeStatus.PENDING_ADMIN.value]:
|
||||||
|
raise HTTPException(status_code=400, detail="Dispute is already resolved")
|
||||||
|
|
||||||
|
# Determine result
|
||||||
|
if data.is_valid:
|
||||||
|
result_status = DisputeStatus.RESOLVED_VALID.value
|
||||||
|
action_type = AdminActionType.DISPUTE_RESOLVE_VALID.value
|
||||||
|
else:
|
||||||
|
result_status = DisputeStatus.RESOLVED_INVALID.value
|
||||||
|
action_type = AdminActionType.DISPUTE_RESOLVE_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()
|
||||||
|
|
||||||
|
# Get details for logging
|
||||||
|
if dispute.bonus_assignment_id:
|
||||||
|
challenge_title = f"Бонус: {dispute.bonus_assignment.challenge.title}"
|
||||||
|
marathon_id = dispute.bonus_assignment.main_assignment.game.marathon_id
|
||||||
|
elif dispute.assignment.is_playthrough:
|
||||||
|
challenge_title = f"Прохождение: {dispute.assignment.game.title}"
|
||||||
|
marathon_id = dispute.assignment.game.marathon_id
|
||||||
|
else:
|
||||||
|
challenge_title = dispute.assignment.challenge.title
|
||||||
|
marathon_id = dispute.assignment.challenge.game.marathon_id
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, action_type,
|
||||||
|
"dispute", dispute_id,
|
||||||
|
{
|
||||||
|
"challenge_title": challenge_title,
|
||||||
|
"marathon_id": marathon_id,
|
||||||
|
"is_valid": data.is_valid,
|
||||||
|
},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send notification
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
|
if dispute.bonus_assignment_id:
|
||||||
|
participant_user_id = dispute.bonus_assignment.main_assignment.participant.user_id
|
||||||
|
else:
|
||||||
|
participant_user_id = dispute.assignment.participant.user_id
|
||||||
|
|
||||||
|
marathon_result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = marathon_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if marathon:
|
||||||
|
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'}"
|
||||||
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -59,9 +59,15 @@ async def login(request: Request, data: UserLogin, db: DbSession):
|
|||||||
|
|
||||||
# Check if user is banned
|
# Check if user is banned
|
||||||
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Your account has been banned",
|
detail=ban_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
# If admin with Telegram linked, require 2FA
|
# If admin with Telegram linked, require 2FA
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ def build_challenge_response(challenge: Challenge, game: Game) -> ChallengeRespo
|
|||||||
estimated_time=challenge.estimated_time,
|
estimated_time=challenge.estimated_time,
|
||||||
proof_type=challenge.proof_type,
|
proof_type=challenge.proof_type,
|
||||||
proof_hint=challenge.proof_hint,
|
proof_hint=challenge.proof_hint,
|
||||||
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url),
|
||||||
is_generated=challenge.is_generated,
|
is_generated=challenge.is_generated,
|
||||||
created_at=challenge.created_at,
|
created_at=challenge.created_at,
|
||||||
status=challenge.status,
|
status=challenge.status,
|
||||||
|
|||||||
@@ -937,6 +937,7 @@ def assignment_to_response(assignment: Assignment) -> AssignmentResponse:
|
|||||||
id=game.id,
|
id=game.id,
|
||||||
title=game.title,
|
title=game.title,
|
||||||
cover_url=storage_service.get_url(game.cover_path, "covers"),
|
cover_url=storage_service.get_url(game.cover_path, "covers"),
|
||||||
|
download_url=game.download_url,
|
||||||
),
|
),
|
||||||
is_generated=challenge.is_generated,
|
is_generated=challenge.is_generated,
|
||||||
created_at=challenge.created_at,
|
created_at=challenge.created_at,
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ from app.api.deps import (
|
|||||||
require_participant, require_organizer, get_participant,
|
require_participant, require_organizer, get_participant,
|
||||||
)
|
)
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType
|
from app.models import (
|
||||||
|
Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, GameType,
|
||||||
|
Challenge, Activity, ActivityType, Assignment, AssignmentStatus, Participant
|
||||||
|
)
|
||||||
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
|
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
|
||||||
|
from app.schemas.assignment import AvailableGamesCount
|
||||||
from app.services.storage import storage_service
|
from app.services.storage import storage_service
|
||||||
from app.services.telegram_notifier import telegram_notifier
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
@@ -43,6 +47,12 @@ def game_to_response(game: Game, challenges_count: int = 0) -> GameResponse:
|
|||||||
approved_by=UserPublic.model_validate(game.approved_by) if game.approved_by else None,
|
approved_by=UserPublic.model_validate(game.approved_by) if game.approved_by else None,
|
||||||
challenges_count=challenges_count,
|
challenges_count=challenges_count,
|
||||||
created_at=game.created_at,
|
created_at=game.created_at,
|
||||||
|
# Поля для типа игры
|
||||||
|
game_type=game.game_type,
|
||||||
|
playthrough_points=game.playthrough_points,
|
||||||
|
playthrough_description=game.playthrough_description,
|
||||||
|
playthrough_proof_type=game.playthrough_proof_type,
|
||||||
|
playthrough_proof_hint=game.playthrough_proof_hint,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -145,6 +155,12 @@ async def add_game(
|
|||||||
proposed_by_id=current_user.id,
|
proposed_by_id=current_user.id,
|
||||||
status=game_status,
|
status=game_status,
|
||||||
approved_by_id=current_user.id if is_organizer else None,
|
approved_by_id=current_user.id if is_organizer else None,
|
||||||
|
# Поля для типа игры
|
||||||
|
game_type=data.game_type.value,
|
||||||
|
playthrough_points=data.playthrough_points,
|
||||||
|
playthrough_description=data.playthrough_description,
|
||||||
|
playthrough_proof_type=data.playthrough_proof_type.value if data.playthrough_proof_type else None,
|
||||||
|
playthrough_proof_hint=data.playthrough_proof_hint,
|
||||||
)
|
)
|
||||||
db.add(game)
|
db.add(game)
|
||||||
|
|
||||||
@@ -171,6 +187,12 @@ async def add_game(
|
|||||||
approved_by=UserPublic.model_validate(current_user) if is_organizer else None,
|
approved_by=UserPublic.model_validate(current_user) if is_organizer else None,
|
||||||
challenges_count=0,
|
challenges_count=0,
|
||||||
created_at=game.created_at,
|
created_at=game.created_at,
|
||||||
|
# Поля для типа игры
|
||||||
|
game_type=game.game_type,
|
||||||
|
playthrough_points=game.playthrough_points,
|
||||||
|
playthrough_description=game.playthrough_description,
|
||||||
|
playthrough_proof_type=game.playthrough_proof_type,
|
||||||
|
playthrough_proof_hint=game.playthrough_proof_hint,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -227,6 +249,18 @@ async def update_game(
|
|||||||
if data.genre is not None:
|
if data.genre is not None:
|
||||||
game.genre = data.genre
|
game.genre = data.genre
|
||||||
|
|
||||||
|
# Поля для типа игры
|
||||||
|
if data.game_type is not None:
|
||||||
|
game.game_type = data.game_type.value
|
||||||
|
if data.playthrough_points is not None:
|
||||||
|
game.playthrough_points = data.playthrough_points
|
||||||
|
if data.playthrough_description is not None:
|
||||||
|
game.playthrough_description = data.playthrough_description
|
||||||
|
if data.playthrough_proof_type is not None:
|
||||||
|
game.playthrough_proof_type = data.playthrough_proof_type.value
|
||||||
|
if data.playthrough_proof_hint is not None:
|
||||||
|
game.playthrough_proof_hint = data.playthrough_proof_hint
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return await get_game(game_id, current_user, db)
|
return await get_game(game_id, current_user, db)
|
||||||
@@ -398,3 +432,159 @@ async def upload_cover(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return await get_game(game_id, current_user, db)
|
return await get_game(game_id, current_user, db)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_available_games_for_participant(
|
||||||
|
db, participant: Participant, marathon_id: int
|
||||||
|
) -> tuple[list[Game], int]:
|
||||||
|
"""
|
||||||
|
Получить список игр, доступных для спина участника.
|
||||||
|
|
||||||
|
Возвращает кортеж (доступные игры, всего игр).
|
||||||
|
|
||||||
|
Логика исключения:
|
||||||
|
- playthrough: игра исключается если участник завершил ИЛИ дропнул прохождение
|
||||||
|
- challenges: игра исключается если участник выполнил ВСЕ челленджи
|
||||||
|
"""
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
# Получаем все одобренные игры с челленджами
|
||||||
|
result = await db.execute(
|
||||||
|
select(Game)
|
||||||
|
.options(selectinload(Game.challenges))
|
||||||
|
.where(
|
||||||
|
Game.marathon_id == marathon_id,
|
||||||
|
Game.status == GameStatus.APPROVED.value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
all_games = list(result.scalars().all())
|
||||||
|
|
||||||
|
# Фильтруем игры с челленджами (для типа challenges)
|
||||||
|
# или игры с заполненными playthrough полями (для типа playthrough)
|
||||||
|
games_with_content = []
|
||||||
|
for game in all_games:
|
||||||
|
if game.game_type == GameType.PLAYTHROUGH.value:
|
||||||
|
# Для playthrough не нужны челленджи
|
||||||
|
if game.playthrough_points and game.playthrough_description:
|
||||||
|
games_with_content.append(game)
|
||||||
|
else:
|
||||||
|
# Для challenges нужны челленджи
|
||||||
|
if game.challenges:
|
||||||
|
games_with_content.append(game)
|
||||||
|
|
||||||
|
total_games = len(games_with_content)
|
||||||
|
if total_games == 0:
|
||||||
|
return [], 0
|
||||||
|
|
||||||
|
# Получаем завершённые/дропнутые assignments участника
|
||||||
|
finished_statuses = [AssignmentStatus.COMPLETED.value, AssignmentStatus.DROPPED.value]
|
||||||
|
|
||||||
|
# Для playthrough: получаем game_id завершённых/дропнутых прохождений
|
||||||
|
playthrough_result = await db.execute(
|
||||||
|
select(Assignment.game_id)
|
||||||
|
.where(
|
||||||
|
Assignment.participant_id == participant.id,
|
||||||
|
Assignment.is_playthrough == True,
|
||||||
|
Assignment.status.in_(finished_statuses)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finished_playthrough_game_ids = set(playthrough_result.scalars().all())
|
||||||
|
|
||||||
|
# Для challenges: получаем challenge_id завершённых заданий
|
||||||
|
challenges_result = await db.execute(
|
||||||
|
select(Assignment.challenge_id)
|
||||||
|
.where(
|
||||||
|
Assignment.participant_id == participant.id,
|
||||||
|
Assignment.is_playthrough == False,
|
||||||
|
Assignment.status == AssignmentStatus.COMPLETED.value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
completed_challenge_ids = set(challenges_result.scalars().all())
|
||||||
|
|
||||||
|
# Фильтруем доступные игры
|
||||||
|
available_games = []
|
||||||
|
for game in games_with_content:
|
||||||
|
if game.game_type == GameType.PLAYTHROUGH.value:
|
||||||
|
# Исключаем если игра уже завершена/дропнута
|
||||||
|
if game.id not in finished_playthrough_game_ids:
|
||||||
|
available_games.append(game)
|
||||||
|
else:
|
||||||
|
# Для challenges: исключаем если все челленджи выполнены
|
||||||
|
game_challenge_ids = {c.id for c in game.challenges}
|
||||||
|
if not game_challenge_ids.issubset(completed_challenge_ids):
|
||||||
|
available_games.append(game)
|
||||||
|
|
||||||
|
return available_games, total_games
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathons/{marathon_id}/available-games-count", response_model=AvailableGamesCount)
|
||||||
|
async def get_available_games_count(
|
||||||
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Получить количество игр, доступных для спина.
|
||||||
|
|
||||||
|
Возвращает { available: X, total: Y } где:
|
||||||
|
- available: количество игр, которые могут выпасть
|
||||||
|
- total: общее количество игр в марафоне
|
||||||
|
"""
|
||||||
|
participant = await get_participant(db, current_user.id, marathon_id)
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
|
available_games, total_games = await get_available_games_for_participant(
|
||||||
|
db, participant, marathon_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return AvailableGamesCount(
|
||||||
|
available=len(available_games),
|
||||||
|
total=total_games
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathons/{marathon_id}/available-games", response_model=list[GameResponse])
|
||||||
|
async def get_available_games(
|
||||||
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Получить список игр, доступных для спина.
|
||||||
|
|
||||||
|
Возвращает только те игры, которые могут выпасть участнику:
|
||||||
|
- Для playthrough: исключаются игры которые уже завершены/дропнуты
|
||||||
|
- Для challenges: исключаются игры где все челленджи выполнены
|
||||||
|
"""
|
||||||
|
participant = await get_participant(db, current_user.id, marathon_id)
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
|
available_games, _ = await get_available_games_for_participant(
|
||||||
|
db, participant, marathon_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert to response with challenges count
|
||||||
|
result = []
|
||||||
|
for game in available_games:
|
||||||
|
challenges_count = len(game.challenges) if game.challenges else 0
|
||||||
|
result.append(GameResponse(
|
||||||
|
id=game.id,
|
||||||
|
title=game.title,
|
||||||
|
cover_url=storage_service.get_url(game.cover_path, "covers"),
|
||||||
|
download_url=game.download_url,
|
||||||
|
genre=game.genre,
|
||||||
|
status=game.status,
|
||||||
|
proposed_by=None,
|
||||||
|
approved_by=None,
|
||||||
|
challenges_count=challenges_count,
|
||||||
|
created_at=game.created_at,
|
||||||
|
game_type=game.game_type,
|
||||||
|
playthrough_points=game.playthrough_points,
|
||||||
|
playthrough_description=game.playthrough_description,
|
||||||
|
playthrough_proof_type=game.playthrough_proof_type,
|
||||||
|
playthrough_proof_hint=game.playthrough_proof_hint,
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
from fastapi import APIRouter, HTTPException, status, Depends
|
from fastapi import APIRouter, HTTPException, status, Depends, UploadFile, File, Response
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
@@ -11,13 +11,16 @@ from app.api.deps import (
|
|||||||
require_participant, require_organizer, require_creator,
|
require_participant, require_organizer, require_creator,
|
||||||
get_participant,
|
get_participant,
|
||||||
)
|
)
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.security import decode_access_token
|
from app.core.security import decode_access_token
|
||||||
|
from app.services.storage import storage_service
|
||||||
|
|
||||||
# Optional auth for endpoints that need it conditionally
|
# Optional auth for endpoints that need it conditionally
|
||||||
optional_auth = HTTPBearer(auto_error=False)
|
optional_auth = HTTPBearer(auto_error=False)
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
|
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
|
||||||
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
|
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
|
||||||
|
Dispute, DisputeStatus, BonusAssignment, BonusAssignmentStatus,
|
||||||
)
|
)
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
MarathonCreate,
|
MarathonCreate,
|
||||||
@@ -62,6 +65,7 @@ async def get_marathon_by_code(invite_code: str, db: DbSession):
|
|||||||
title=marathon.title,
|
title=marathon.title,
|
||||||
description=marathon.description,
|
description=marathon.description,
|
||||||
status=marathon.status,
|
status=marathon.status,
|
||||||
|
cover_url=marathon.cover_url,
|
||||||
participants_count=participants_count,
|
participants_count=participants_count,
|
||||||
creator_nickname=marathon.creator.nickname,
|
creator_nickname=marathon.creator.nickname,
|
||||||
)
|
)
|
||||||
@@ -128,6 +132,7 @@ async def list_marathons(current_user: CurrentUser, db: DbSession):
|
|||||||
title=marathon.title,
|
title=marathon.title,
|
||||||
status=marathon.status,
|
status=marathon.status,
|
||||||
is_public=marathon.is_public,
|
is_public=marathon.is_public,
|
||||||
|
cover_url=marathon.cover_url,
|
||||||
participants_count=row[1],
|
participants_count=row[1],
|
||||||
start_date=marathon.start_date,
|
start_date=marathon.start_date,
|
||||||
end_date=marathon.end_date,
|
end_date=marathon.end_date,
|
||||||
@@ -180,6 +185,7 @@ async def create_marathon(
|
|||||||
is_public=marathon.is_public,
|
is_public=marathon.is_public,
|
||||||
game_proposal_mode=marathon.game_proposal_mode,
|
game_proposal_mode=marathon.game_proposal_mode,
|
||||||
auto_events_enabled=marathon.auto_events_enabled,
|
auto_events_enabled=marathon.auto_events_enabled,
|
||||||
|
cover_url=marathon.cover_url,
|
||||||
start_date=marathon.start_date,
|
start_date=marathon.start_date,
|
||||||
end_date=marathon.end_date,
|
end_date=marathon.end_date,
|
||||||
participants_count=1,
|
participants_count=1,
|
||||||
@@ -226,6 +232,7 @@ async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSessio
|
|||||||
is_public=marathon.is_public,
|
is_public=marathon.is_public,
|
||||||
game_proposal_mode=marathon.game_proposal_mode,
|
game_proposal_mode=marathon.game_proposal_mode,
|
||||||
auto_events_enabled=marathon.auto_events_enabled,
|
auto_events_enabled=marathon.auto_events_enabled,
|
||||||
|
cover_url=marathon.cover_url,
|
||||||
start_date=marathon.start_date,
|
start_date=marathon.start_date,
|
||||||
end_date=marathon.end_date,
|
end_date=marathon.end_date,
|
||||||
participants_count=participants_count,
|
participants_count=participants_count,
|
||||||
@@ -301,9 +308,12 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
|
|||||||
if len(approved_games) == 0:
|
if len(approved_games) == 0:
|
||||||
raise HTTPException(status_code=400, detail="Добавьте и одобрите хотя бы одну игру")
|
raise HTTPException(status_code=400, detail="Добавьте и одобрите хотя бы одну игру")
|
||||||
|
|
||||||
# Check that all approved games have at least one challenge
|
# Check that all approved challenge-based games have at least one challenge
|
||||||
|
# Playthrough games don't need challenges
|
||||||
games_without_challenges = []
|
games_without_challenges = []
|
||||||
for game in approved_games:
|
for game in approved_games:
|
||||||
|
if game.is_playthrough:
|
||||||
|
continue # Игры типа "Прохождение" не требуют челленджей
|
||||||
challenge_count = await db.scalar(
|
challenge_count = await db.scalar(
|
||||||
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
|
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
|
||||||
)
|
)
|
||||||
@@ -591,3 +601,366 @@ async def get_leaderboard(
|
|||||||
))
|
))
|
||||||
|
|
||||||
return leaderboard
|
return leaderboard
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{marathon_id}/cover")
|
||||||
|
async def get_marathon_cover(marathon_id: int, db: DbSession):
|
||||||
|
"""Get marathon cover image"""
|
||||||
|
marathon = await get_marathon_or_404(db, marathon_id)
|
||||||
|
|
||||||
|
if not marathon.cover_path:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon has no cover")
|
||||||
|
|
||||||
|
file_data = await storage_service.get_file(marathon.cover_path, "covers")
|
||||||
|
if not file_data:
|
||||||
|
raise HTTPException(status_code=404, detail="Cover not found in storage")
|
||||||
|
|
||||||
|
content, content_type = file_data
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type=content_type,
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "public, max-age=3600",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{marathon_id}/cover", response_model=MarathonResponse)
|
||||||
|
async def upload_marathon_cover(
|
||||||
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
):
|
||||||
|
"""Upload marathon cover image (organizers only, preparing status)"""
|
||||||
|
await require_organizer(db, current_user, marathon_id)
|
||||||
|
marathon = await get_marathon_or_404(db, marathon_id)
|
||||||
|
|
||||||
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot update cover of active or finished marathon")
|
||||||
|
|
||||||
|
# Validate file
|
||||||
|
if not file.content_type or not file.content_type.startswith("image/"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="File must be an image",
|
||||||
|
)
|
||||||
|
|
||||||
|
contents = await file.read()
|
||||||
|
if len(contents) > settings.MAX_UPLOAD_SIZE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get file extension
|
||||||
|
ext = file.filename.split(".")[-1].lower() if file.filename else "jpg"
|
||||||
|
if ext not in settings.ALLOWED_IMAGE_EXTENSIONS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid file type. Allowed: {settings.ALLOWED_IMAGE_EXTENSIONS}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete old cover if exists
|
||||||
|
if marathon.cover_path:
|
||||||
|
await storage_service.delete_file(marathon.cover_path)
|
||||||
|
|
||||||
|
# Upload file
|
||||||
|
filename = storage_service.generate_filename(marathon_id, file.filename)
|
||||||
|
file_path = await storage_service.upload_file(
|
||||||
|
content=contents,
|
||||||
|
folder="covers",
|
||||||
|
filename=filename,
|
||||||
|
content_type=file.content_type or "image/jpeg",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update marathon with cover path and URL
|
||||||
|
marathon.cover_path = file_path
|
||||||
|
marathon.cover_url = f"/api/v1/marathons/{marathon_id}/cover"
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return await get_marathon(marathon_id, current_user, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{marathon_id}/cover", response_model=MarathonResponse)
|
||||||
|
async def delete_marathon_cover(
|
||||||
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Delete marathon cover image (organizers only, preparing status)"""
|
||||||
|
await require_organizer(db, current_user, marathon_id)
|
||||||
|
marathon = await get_marathon_or_404(db, marathon_id)
|
||||||
|
|
||||||
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot update cover of active or finished marathon")
|
||||||
|
|
||||||
|
if not marathon.cover_path:
|
||||||
|
raise HTTPException(status_code=400, detail="Marathon has no cover")
|
||||||
|
|
||||||
|
# Delete file from storage
|
||||||
|
await storage_service.delete_file(marathon.cover_path)
|
||||||
|
|
||||||
|
marathon.cover_path = None
|
||||||
|
marathon.cover_url = None
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return await get_marathon(marathon_id, current_user, db)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Marathon Disputes (for organizers) ============
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class MarathonDisputeResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
assignment_id: int | None
|
||||||
|
bonus_assignment_id: int | None
|
||||||
|
challenge_title: str
|
||||||
|
participant_nickname: str
|
||||||
|
raised_by_nickname: str
|
||||||
|
reason: str
|
||||||
|
status: str
|
||||||
|
votes_valid: int
|
||||||
|
votes_invalid: int
|
||||||
|
created_at: str
|
||||||
|
expires_at: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveDisputeRequest(BaseModel):
|
||||||
|
is_valid: bool = Field(..., description="True = proof is valid, False = proof is invalid")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{marathon_id}/disputes", response_model=list[MarathonDisputeResponse])
|
||||||
|
async def list_marathon_disputes(
|
||||||
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
status_filter: str = "open",
|
||||||
|
):
|
||||||
|
"""List disputes in a marathon. Organizers only."""
|
||||||
|
await require_organizer(db, current_user, marathon_id)
|
||||||
|
marathon = await get_marathon_or_404(db, marathon_id)
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
DISPUTE_WINDOW_HOURS = 24
|
||||||
|
|
||||||
|
# Get all assignments in this marathon (through games)
|
||||||
|
games_result = await db.execute(
|
||||||
|
select(Game.id).where(Game.marathon_id == marathon_id)
|
||||||
|
)
|
||||||
|
game_ids = [g[0] for g in games_result.all()]
|
||||||
|
|
||||||
|
if not game_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Get disputes for assignments in these games
|
||||||
|
# Using selectinload for eager loading - no explicit joins needed
|
||||||
|
query = (
|
||||||
|
select(Dispute)
|
||||||
|
.options(
|
||||||
|
selectinload(Dispute.raised_by),
|
||||||
|
selectinload(Dispute.votes),
|
||||||
|
selectinload(Dispute.assignment).selectinload(Assignment.participant).selectinload(Participant.user),
|
||||||
|
selectinload(Dispute.assignment).selectinload(Assignment.challenge),
|
||||||
|
selectinload(Dispute.assignment).selectinload(Assignment.game),
|
||||||
|
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant).selectinload(Participant.user),
|
||||||
|
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
|
||||||
|
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
|
||||||
|
)
|
||||||
|
.order_by(Dispute.created_at.desc())
|
||||||
|
)
|
||||||
|
|
||||||
|
if status_filter == "open":
|
||||||
|
query = query.where(Dispute.status == DisputeStatus.OPEN.value)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
all_disputes = result.scalars().unique().all()
|
||||||
|
|
||||||
|
# Filter disputes that belong to this marathon's games
|
||||||
|
response = []
|
||||||
|
for dispute in all_disputes:
|
||||||
|
# Check if dispute belongs to this marathon
|
||||||
|
if dispute.bonus_assignment_id:
|
||||||
|
bonus = dispute.bonus_assignment
|
||||||
|
if not bonus or not bonus.main_assignment:
|
||||||
|
continue
|
||||||
|
if bonus.main_assignment.game_id not in game_ids:
|
||||||
|
continue
|
||||||
|
participant = bonus.main_assignment.participant
|
||||||
|
challenge_title = f"Бонус: {bonus.challenge.title}"
|
||||||
|
else:
|
||||||
|
assignment = dispute.assignment
|
||||||
|
if not assignment:
|
||||||
|
continue
|
||||||
|
if assignment.is_playthrough:
|
||||||
|
if assignment.game_id not in game_ids:
|
||||||
|
continue
|
||||||
|
challenge_title = f"Прохождение: {assignment.game.title}"
|
||||||
|
else:
|
||||||
|
if not assignment.challenge or assignment.challenge.game_id not in game_ids:
|
||||||
|
continue
|
||||||
|
challenge_title = assignment.challenge.title
|
||||||
|
participant = assignment.participant
|
||||||
|
|
||||||
|
# Count votes
|
||||||
|
votes_valid = sum(1 for v in dispute.votes if v.vote is True)
|
||||||
|
votes_invalid = sum(1 for v in dispute.votes if v.vote is False)
|
||||||
|
|
||||||
|
# Calculate expiry
|
||||||
|
expires_at = dispute.created_at + timedelta(hours=DISPUTE_WINDOW_HOURS)
|
||||||
|
|
||||||
|
response.append(MarathonDisputeResponse(
|
||||||
|
id=dispute.id,
|
||||||
|
assignment_id=dispute.assignment_id,
|
||||||
|
bonus_assignment_id=dispute.bonus_assignment_id,
|
||||||
|
challenge_title=challenge_title,
|
||||||
|
participant_nickname=participant.user.nickname,
|
||||||
|
raised_by_nickname=dispute.raised_by.nickname,
|
||||||
|
reason=dispute.reason,
|
||||||
|
status=dispute.status,
|
||||||
|
votes_valid=votes_valid,
|
||||||
|
votes_invalid=votes_invalid,
|
||||||
|
created_at=dispute.created_at.isoformat(),
|
||||||
|
expires_at=expires_at.isoformat(),
|
||||||
|
))
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{marathon_id}/disputes/{dispute_id}/resolve", response_model=MessageResponse)
|
||||||
|
async def resolve_marathon_dispute(
|
||||||
|
marathon_id: int,
|
||||||
|
dispute_id: int,
|
||||||
|
data: ResolveDisputeRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Manually resolve a dispute in a marathon. Organizers only."""
|
||||||
|
await require_organizer(db, current_user, marathon_id)
|
||||||
|
marathon = await get_marathon_or_404(db, marathon_id)
|
||||||
|
|
||||||
|
# Get dispute
|
||||||
|
result = await db.execute(
|
||||||
|
select(Dispute)
|
||||||
|
.options(
|
||||||
|
selectinload(Dispute.assignment).selectinload(Assignment.participant),
|
||||||
|
selectinload(Dispute.assignment).selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
selectinload(Dispute.assignment).selectinload(Assignment.game),
|
||||||
|
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant),
|
||||||
|
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.game),
|
||||||
|
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.challenge),
|
||||||
|
)
|
||||||
|
.where(Dispute.id == dispute_id)
|
||||||
|
)
|
||||||
|
dispute = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not dispute:
|
||||||
|
raise HTTPException(status_code=404, detail="Dispute not found")
|
||||||
|
|
||||||
|
# Verify dispute belongs to this marathon
|
||||||
|
if dispute.bonus_assignment_id:
|
||||||
|
bonus = dispute.bonus_assignment
|
||||||
|
if bonus.main_assignment.game.marathon_id != marathon_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
|
||||||
|
else:
|
||||||
|
assignment = dispute.assignment
|
||||||
|
if assignment.is_playthrough:
|
||||||
|
if assignment.game.marathon_id != marathon_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
|
||||||
|
else:
|
||||||
|
if assignment.challenge.game.marathon_id != marathon_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Dispute does not belong to this marathon")
|
||||||
|
|
||||||
|
if dispute.status != DisputeStatus.OPEN.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Dispute is already resolved")
|
||||||
|
|
||||||
|
# Determine result
|
||||||
|
if data.is_valid:
|
||||||
|
result_status = DisputeStatus.RESOLVED_VALID.value
|
||||||
|
else:
|
||||||
|
result_status = DisputeStatus.RESOLVED_INVALID.value
|
||||||
|
|
||||||
|
# Handle invalid proof
|
||||||
|
if dispute.bonus_assignment_id:
|
||||||
|
# Reset bonus assignment
|
||||||
|
bonus = dispute.bonus_assignment
|
||||||
|
main_assignment = bonus.main_assignment
|
||||||
|
participant = main_assignment.participant
|
||||||
|
|
||||||
|
# Only subtract points if main playthrough was already completed
|
||||||
|
# (bonus points are added only when main playthrough is completed)
|
||||||
|
if main_assignment.status == AssignmentStatus.COMPLETED.value:
|
||||||
|
points_to_subtract = bonus.points_earned
|
||||||
|
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
||||||
|
# Also reduce the points_earned on the main assignment
|
||||||
|
main_assignment.points_earned = max(0, main_assignment.points_earned - points_to_subtract)
|
||||||
|
|
||||||
|
bonus.status = BonusAssignmentStatus.PENDING.value
|
||||||
|
bonus.proof_path = None
|
||||||
|
bonus.proof_url = None
|
||||||
|
bonus.proof_comment = None
|
||||||
|
bonus.points_earned = 0
|
||||||
|
bonus.completed_at = None
|
||||||
|
else:
|
||||||
|
# Reset main assignment
|
||||||
|
assignment = dispute.assignment
|
||||||
|
participant = assignment.participant
|
||||||
|
|
||||||
|
# Subtract points
|
||||||
|
points_to_subtract = assignment.points_earned
|
||||||
|
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
||||||
|
|
||||||
|
# Reset streak - the completion was invalid
|
||||||
|
participant.current_streak = 0
|
||||||
|
|
||||||
|
# Reset assignment
|
||||||
|
assignment.status = AssignmentStatus.RETURNED.value
|
||||||
|
assignment.points_earned = 0
|
||||||
|
|
||||||
|
# For playthrough: reset all bonus assignments
|
||||||
|
if assignment.is_playthrough:
|
||||||
|
bonus_result = await db.execute(
|
||||||
|
select(BonusAssignment).where(BonusAssignment.main_assignment_id == assignment.id)
|
||||||
|
)
|
||||||
|
for ba in bonus_result.scalars().all():
|
||||||
|
ba.status = BonusAssignmentStatus.PENDING.value
|
||||||
|
ba.proof_path = None
|
||||||
|
ba.proof_url = None
|
||||||
|
ba.proof_comment = None
|
||||||
|
ba.points_earned = 0
|
||||||
|
ba.completed_at = None
|
||||||
|
|
||||||
|
# Update dispute
|
||||||
|
dispute.status = result_status
|
||||||
|
dispute.resolved_at = datetime.utcnow()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Send notification
|
||||||
|
if dispute.bonus_assignment_id:
|
||||||
|
participant_user_id = dispute.bonus_assignment.main_assignment.participant.user_id
|
||||||
|
challenge_title = f"Бонус: {dispute.bonus_assignment.challenge.title}"
|
||||||
|
elif dispute.assignment.is_playthrough:
|
||||||
|
participant_user_id = dispute.assignment.participant.user_id
|
||||||
|
challenge_title = f"Прохождение: {dispute.assignment.game.title}"
|
||||||
|
else:
|
||||||
|
participant_user_id = dispute.assignment.participant.user_id
|
||||||
|
challenge_title = dispute.assignment.challenge.title
|
||||||
|
|
||||||
|
await telegram_notifier.notify_dispute_resolved(
|
||||||
|
db,
|
||||||
|
user_id=participant_user_id,
|
||||||
|
marathon_title=marathon.title,
|
||||||
|
challenge_title=challenge_title,
|
||||||
|
is_valid=data.is_valid
|
||||||
|
)
|
||||||
|
|
||||||
|
return MessageResponse(
|
||||||
|
message=f"Dispute resolved as {'valid' if data.is_valid else 'invalid'}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -9,15 +9,18 @@ from app.core.config import settings
|
|||||||
from app.models import (
|
from app.models import (
|
||||||
Marathon, MarathonStatus, Game, Challenge, Participant,
|
Marathon, MarathonStatus, Game, Challenge, Participant,
|
||||||
Assignment, AssignmentStatus, Activity, ActivityType,
|
Assignment, AssignmentStatus, Activity, ActivityType,
|
||||||
EventType, Difficulty, User
|
EventType, Difficulty, User, BonusAssignment, BonusAssignmentStatus, GameType,
|
||||||
|
DisputeStatus,
|
||||||
)
|
)
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
SpinResult, AssignmentResponse, CompleteResult, DropResult,
|
SpinResult, AssignmentResponse, CompleteResult, DropResult,
|
||||||
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse
|
GameResponse, ChallengeResponse, GameShort, UserPublic, MessageResponse,
|
||||||
)
|
)
|
||||||
|
from app.schemas.game import PlaythroughInfo
|
||||||
from app.services.points import PointsService
|
from app.services.points import PointsService
|
||||||
from app.services.events import event_service
|
from app.services.events import event_service
|
||||||
from app.services.storage import storage_service
|
from app.services.storage import storage_service
|
||||||
|
from app.api.v1.games import get_available_games_for_participant
|
||||||
|
|
||||||
router = APIRouter(tags=["wheel"])
|
router = APIRouter(tags=["wheel"])
|
||||||
|
|
||||||
@@ -48,7 +51,9 @@ async def get_active_assignment(db, participant_id: int, is_event: bool = False)
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Assignment)
|
select(Assignment)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
selectinload(Assignment.game).selectinload(Game.challenges), # For playthrough
|
||||||
|
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Assignment.participant_id == participant_id,
|
Assignment.participant_id == participant_id,
|
||||||
@@ -64,7 +69,9 @@ async def get_oldest_returned_assignment(db, participant_id: int) -> Assignment
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Assignment)
|
select(Assignment)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
selectinload(Assignment.game).selectinload(Game.challenges), # For playthrough
|
||||||
|
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Assignment.participant_id == participant_id,
|
Assignment.participant_id == participant_id,
|
||||||
@@ -94,7 +101,7 @@ async def activate_returned_assignment(db, returned_assignment: Assignment) -> N
|
|||||||
|
|
||||||
@router.post("/marathons/{marathon_id}/spin", response_model=SpinResult)
|
@router.post("/marathons/{marathon_id}/spin", response_model=SpinResult)
|
||||||
async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
"""Spin the wheel to get a random game and challenge"""
|
"""Spin the wheel to get a random game and challenge (or playthrough)"""
|
||||||
# Check marathon is active
|
# Check marathon is active
|
||||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
marathon = result.scalar_one_or_none()
|
marathon = result.scalar_one_or_none()
|
||||||
@@ -115,60 +122,127 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
|||||||
if active:
|
if active:
|
||||||
raise HTTPException(status_code=400, detail="You already have an active assignment")
|
raise HTTPException(status_code=400, detail="You already have an active assignment")
|
||||||
|
|
||||||
|
# Get available games (filtered by completion status)
|
||||||
|
available_games, _ = await get_available_games_for_participant(db, participant, marathon_id)
|
||||||
|
|
||||||
|
if not available_games:
|
||||||
|
raise HTTPException(status_code=400, detail="No games available for spin")
|
||||||
|
|
||||||
# Check active event
|
# Check active event
|
||||||
active_event = await event_service.get_active_event(db, marathon_id)
|
active_event = await event_service.get_active_event(db, marathon_id)
|
||||||
|
|
||||||
game = None
|
game = None
|
||||||
challenge = None
|
challenge = None
|
||||||
|
is_playthrough = False
|
||||||
|
|
||||||
# Handle special event cases (excluding Common Enemy - it has separate flow)
|
# Handle special event cases (excluding Common Enemy - it has separate flow)
|
||||||
|
# Events only apply to challenges-type games, not playthrough
|
||||||
if active_event:
|
if active_event:
|
||||||
if active_event.type == EventType.JACKPOT.value:
|
if active_event.type == EventType.JACKPOT.value:
|
||||||
# Jackpot: Get hard challenge only
|
# Jackpot: Get hard challenge only (from challenges-type games)
|
||||||
challenge = await event_service.get_random_hard_challenge(db, marathon_id)
|
challenge = await event_service.get_random_hard_challenge(db, marathon_id)
|
||||||
if challenge:
|
if challenge:
|
||||||
# Load game for challenge
|
# Check if this game is available for the participant
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Game).where(Game.id == challenge.game_id)
|
select(Game).where(Game.id == challenge.game_id)
|
||||||
)
|
)
|
||||||
game = result.scalar_one_or_none()
|
game = result.scalar_one_or_none()
|
||||||
# Consume jackpot (one-time use)
|
if game and game.id in [g.id for g in available_games]:
|
||||||
await event_service.consume_jackpot(db, active_event.id)
|
# 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
|
# Note: Common Enemy is handled separately via event-assignment endpoints
|
||||||
|
|
||||||
# Normal random selection if no special event handling
|
# Normal random selection if no special event handling
|
||||||
if not game or not challenge:
|
if not game:
|
||||||
result = await db.execute(
|
game = random.choice(available_games)
|
||||||
select(Game)
|
|
||||||
.options(selectinload(Game.challenges))
|
if game.game_type == GameType.PLAYTHROUGH.value:
|
||||||
.where(Game.marathon_id == marathon_id)
|
# Playthrough game - no challenge selection, ignore events
|
||||||
|
is_playthrough = True
|
||||||
|
challenge = None
|
||||||
|
active_event = None # Ignore events for playthrough
|
||||||
|
else:
|
||||||
|
# Challenges game - select random challenge
|
||||||
|
if not game.challenges:
|
||||||
|
# Reload challenges if not loaded
|
||||||
|
result = await db.execute(
|
||||||
|
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,
|
||||||
|
# No event_type for playthrough
|
||||||
)
|
)
|
||||||
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:
|
# Create bonus assignments for all challenges
|
||||||
raise HTTPException(status_code=400, detail="No games with challenges available")
|
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)
|
# Log activity
|
||||||
challenge = random.choice(game.challenges)
|
activity_data = {
|
||||||
|
"game": game.title,
|
||||||
|
"is_playthrough": True,
|
||||||
|
"points": game.playthrough_points,
|
||||||
|
"bonus_challenges_count": len(bonus_challenges),
|
||||||
|
}
|
||||||
|
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)
|
# Log activity
|
||||||
assignment = Assignment(
|
activity_data = {
|
||||||
participant_id=participant.id,
|
"game": game.title,
|
||||||
challenge_id=challenge.id,
|
"challenge": challenge.title,
|
||||||
status=AssignmentStatus.ACTIVE.value,
|
"difficulty": challenge.difficulty,
|
||||||
event_type=active_event.type if active_event else None,
|
"points": challenge.points,
|
||||||
)
|
}
|
||||||
db.add(assignment)
|
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(
|
activity = Activity(
|
||||||
marathon_id=marathon_id,
|
marathon_id=marathon_id,
|
||||||
@@ -181,10 +255,17 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(assignment)
|
await db.refresh(assignment)
|
||||||
|
|
||||||
# Calculate drop penalty (considers active event for double_risk)
|
# Calculate drop penalty
|
||||||
drop_penalty = points_service.calculate_drop_penalty(participant.drop_count, challenge.points, active_event)
|
if is_playthrough:
|
||||||
|
drop_penalty = points_service.calculate_drop_penalty(
|
||||||
|
participant.drop_count, game.playthrough_points, None # No events for playthrough
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
drop_penalty = points_service.calculate_drop_penalty(
|
||||||
|
participant.drop_count, challenge.points, active_event
|
||||||
|
)
|
||||||
|
|
||||||
# Get challenges count (avoid lazy loading in async context)
|
# Get challenges count
|
||||||
challenges_count = 0
|
challenges_count = 0
|
||||||
if 'challenges' in game.__dict__:
|
if 'challenges' in game.__dict__:
|
||||||
challenges_count = len(game.challenges)
|
challenges_count = len(game.challenges)
|
||||||
@@ -193,36 +274,80 @@ async def spin_wheel(marathon_id: int, current_user: CurrentUser, db: DbSession)
|
|||||||
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
|
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
return SpinResult(
|
# Build response
|
||||||
assignment_id=assignment.id,
|
game_response = GameResponse(
|
||||||
game=GameResponse(
|
id=game.id,
|
||||||
id=game.id,
|
title=game.title,
|
||||||
title=game.title,
|
cover_url=storage_service.get_url(game.cover_path, "covers"),
|
||||||
cover_url=storage_service.get_url(game.cover_path, "covers"),
|
download_url=game.download_url,
|
||||||
download_url=game.download_url,
|
genre=game.genre,
|
||||||
genre=game.genre,
|
added_by=None,
|
||||||
added_by=None,
|
challenges_count=challenges_count,
|
||||||
challenges_count=challenges_count,
|
created_at=game.created_at,
|
||||||
created_at=game.created_at,
|
game_type=game.game_type,
|
||||||
),
|
playthrough_points=game.playthrough_points,
|
||||||
challenge=ChallengeResponse(
|
playthrough_description=game.playthrough_description,
|
||||||
id=challenge.id,
|
playthrough_proof_type=game.playthrough_proof_type,
|
||||||
title=challenge.title,
|
playthrough_proof_hint=game.playthrough_proof_hint,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if is_playthrough:
|
||||||
|
# Return playthrough result
|
||||||
|
return SpinResult(
|
||||||
|
assignment_id=assignment.id,
|
||||||
|
game=game_response,
|
||||||
|
challenge=None,
|
||||||
|
is_playthrough=True,
|
||||||
|
playthrough_info=PlaythroughInfo(
|
||||||
|
description=game.playthrough_description,
|
||||||
|
points=game.playthrough_points,
|
||||||
|
proof_type=game.playthrough_proof_type,
|
||||||
|
proof_hint=game.playthrough_proof_hint,
|
||||||
|
),
|
||||||
|
bonus_challenges=[
|
||||||
|
ChallengeResponse(
|
||||||
|
id=ch.id,
|
||||||
|
title=ch.title,
|
||||||
|
description=ch.description,
|
||||||
|
type=ch.type,
|
||||||
|
difficulty=ch.difficulty,
|
||||||
|
points=ch.points,
|
||||||
|
estimated_time=ch.estimated_time,
|
||||||
|
proof_type=ch.proof_type,
|
||||||
|
proof_hint=ch.proof_hint,
|
||||||
|
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
|
||||||
|
is_generated=ch.is_generated,
|
||||||
|
created_at=ch.created_at,
|
||||||
|
)
|
||||||
|
for ch in bonus_challenges
|
||||||
|
],
|
||||||
|
can_drop=True,
|
||||||
|
drop_penalty=drop_penalty,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Return challenge result
|
||||||
|
return SpinResult(
|
||||||
|
assignment_id=assignment.id,
|
||||||
|
game=game_response,
|
||||||
|
challenge=ChallengeResponse(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/marathons/{marathon_id}/current-assignment", response_model=AssignmentResponse | None)
|
@router.get("/marathons/{marathon_id}/current-assignment", response_model=AssignmentResponse | None)
|
||||||
async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
@@ -230,9 +355,77 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
|
|||||||
participant = await get_participant_or_403(db, current_user.id, marathon_id)
|
participant = await get_participant_or_403(db, current_user.id, marathon_id)
|
||||||
assignment = await get_active_assignment(db, participant.id, is_event=False)
|
assignment = await get_active_assignment(db, participant.id, is_event=False)
|
||||||
|
|
||||||
|
# If no active assignment, check for returned assignments
|
||||||
|
if not assignment:
|
||||||
|
returned = await get_oldest_returned_assignment(db, participant.id)
|
||||||
|
if returned:
|
||||||
|
# Activate the returned assignment
|
||||||
|
await activate_returned_assignment(db, returned)
|
||||||
|
await db.commit()
|
||||||
|
# Reload with all relationships
|
||||||
|
assignment = await get_active_assignment(db, participant.id, is_event=False)
|
||||||
|
|
||||||
if not assignment:
|
if not assignment:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Handle playthrough assignments
|
||||||
|
if assignment.is_playthrough:
|
||||||
|
game = assignment.game
|
||||||
|
active_event = None # No events for playthrough
|
||||||
|
drop_penalty = points_service.calculate_drop_penalty(
|
||||||
|
participant.drop_count, game.playthrough_points, None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build bonus challenges response
|
||||||
|
from app.schemas.assignment import BonusAssignmentResponse
|
||||||
|
bonus_responses = []
|
||||||
|
for ba in assignment.bonus_assignments:
|
||||||
|
bonus_responses.append(BonusAssignmentResponse(
|
||||||
|
id=ba.id,
|
||||||
|
challenge=ChallengeResponse(
|
||||||
|
id=ba.challenge.id,
|
||||||
|
title=ba.challenge.title,
|
||||||
|
description=ba.challenge.description,
|
||||||
|
type=ba.challenge.type,
|
||||||
|
difficulty=ba.challenge.difficulty,
|
||||||
|
points=ba.challenge.points,
|
||||||
|
estimated_time=ba.challenge.estimated_time,
|
||||||
|
proof_type=ba.challenge.proof_type,
|
||||||
|
proof_hint=ba.challenge.proof_hint,
|
||||||
|
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
|
||||||
|
is_generated=ba.challenge.is_generated,
|
||||||
|
created_at=ba.challenge.created_at,
|
||||||
|
),
|
||||||
|
status=ba.status,
|
||||||
|
proof_url=ba.proof_url,
|
||||||
|
proof_comment=ba.proof_comment,
|
||||||
|
points_earned=ba.points_earned,
|
||||||
|
completed_at=ba.completed_at,
|
||||||
|
))
|
||||||
|
|
||||||
|
return AssignmentResponse(
|
||||||
|
id=assignment.id,
|
||||||
|
challenge=None,
|
||||||
|
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
|
||||||
|
is_playthrough=True,
|
||||||
|
playthrough_info=PlaythroughInfo(
|
||||||
|
description=game.playthrough_description,
|
||||||
|
points=game.playthrough_points,
|
||||||
|
proof_type=game.playthrough_proof_type,
|
||||||
|
proof_hint=game.playthrough_proof_hint,
|
||||||
|
),
|
||||||
|
status=assignment.status,
|
||||||
|
proof_url=storage_service.get_url(assignment.proof_path, "proofs") if assignment.proof_path else assignment.proof_url,
|
||||||
|
proof_comment=assignment.proof_comment,
|
||||||
|
points_earned=assignment.points_earned,
|
||||||
|
streak_at_completion=assignment.streak_at_completion,
|
||||||
|
started_at=assignment.started_at,
|
||||||
|
completed_at=assignment.completed_at,
|
||||||
|
drop_penalty=drop_penalty,
|
||||||
|
bonus_challenges=bonus_responses,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Regular challenge assignment
|
||||||
challenge = assignment.challenge
|
challenge = assignment.challenge
|
||||||
game = challenge.game
|
game = challenge.game
|
||||||
|
|
||||||
@@ -252,7 +445,7 @@ async def get_current_assignment(marathon_id: int, current_user: CurrentUser, db
|
|||||||
estimated_time=challenge.estimated_time,
|
estimated_time=challenge.estimated_time,
|
||||||
proof_type=challenge.proof_type,
|
proof_type=challenge.proof_type,
|
||||||
proof_hint=challenge.proof_hint,
|
proof_hint=challenge.proof_hint,
|
||||||
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
game=GameShort(id=game.id, title=game.title, cover_url=None, download_url=game.download_url, game_type=game.game_type),
|
||||||
is_generated=challenge.is_generated,
|
is_generated=challenge.is_generated,
|
||||||
created_at=challenge.created_at,
|
created_at=challenge.created_at,
|
||||||
),
|
),
|
||||||
@@ -274,15 +467,19 @@ async def complete_assignment(
|
|||||||
db: DbSession,
|
db: DbSession,
|
||||||
proof_url: str | None = Form(None),
|
proof_url: str | None = Form(None),
|
||||||
comment: str | None = Form(None),
|
comment: str | None = Form(None),
|
||||||
proof_file: UploadFile | None = File(None),
|
proof_file: UploadFile | None = File(None), # Legacy single file support
|
||||||
|
proof_files: list[UploadFile] = File([]), # Multiple files support
|
||||||
):
|
):
|
||||||
"""Complete a regular assignment with proof (not event assignments)"""
|
"""Complete a regular assignment with proof (not event assignments)"""
|
||||||
# Get assignment
|
# Get assignment with all needed relationships
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Assignment)
|
select(Assignment)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Assignment.participant),
|
selectinload(Assignment.participant),
|
||||||
selectinload(Assignment.challenge),
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
selectinload(Assignment.game), # For playthrough
|
||||||
|
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For bonus points
|
||||||
|
selectinload(Assignment.dispute), # To check if it was previously disputed
|
||||||
)
|
)
|
||||||
.where(Assignment.id == assignment_id)
|
.where(Assignment.id == assignment_id)
|
||||||
)
|
)
|
||||||
@@ -301,62 +498,157 @@ async def complete_assignment(
|
|||||||
if assignment.is_event_assignment:
|
if assignment.is_event_assignment:
|
||||||
raise HTTPException(status_code=400, detail="Use /event-assignments/{id}/complete for event assignments")
|
raise HTTPException(status_code=400, detail="Use /event-assignments/{id}/complete for event assignments")
|
||||||
|
|
||||||
# Need either file or URL
|
# Combine legacy single file with new multiple files
|
||||||
if not proof_file and not proof_url:
|
all_files = []
|
||||||
raise HTTPException(status_code=400, detail="Proof is required (file or URL)")
|
|
||||||
|
|
||||||
# Handle file upload
|
|
||||||
if proof_file:
|
if proof_file:
|
||||||
contents = await proof_file.read()
|
all_files.append(proof_file)
|
||||||
if len(contents) > settings.MAX_UPLOAD_SIZE:
|
if proof_files:
|
||||||
raise HTTPException(
|
all_files.extend(proof_files)
|
||||||
status_code=400,
|
|
||||||
detail=f"File too large. Maximum size is {settings.MAX_UPLOAD_SIZE // 1024 // 1024} MB",
|
|
||||||
)
|
|
||||||
|
|
||||||
ext = proof_file.filename.split(".")[-1].lower() if proof_file.filename else "jpg"
|
# For playthrough: need either file(s) or URL or comment (proof is flexible)
|
||||||
if ext not in settings.ALLOWED_EXTENSIONS:
|
# For challenges: need either file(s) or URL
|
||||||
raise HTTPException(
|
if assignment.is_playthrough:
|
||||||
status_code=400,
|
if not all_files and not proof_url and not comment:
|
||||||
detail=f"Invalid file type. Allowed: {settings.ALLOWED_EXTENSIONS}",
|
raise HTTPException(status_code=400, detail="Proof is required (file, URL, or comment)")
|
||||||
)
|
|
||||||
|
|
||||||
# Upload file to storage
|
|
||||||
filename = storage_service.generate_filename(assignment_id, proof_file.filename)
|
|
||||||
file_path = await storage_service.upload_file(
|
|
||||||
content=contents,
|
|
||||||
folder="proofs",
|
|
||||||
filename=filename,
|
|
||||||
content_type=proof_file.content_type or "application/octet-stream",
|
|
||||||
)
|
|
||||||
|
|
||||||
assignment.proof_path = file_path
|
|
||||||
else:
|
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_url = proof_url
|
||||||
|
|
||||||
assignment.proof_comment = comment
|
assignment.proof_comment = comment
|
||||||
|
|
||||||
# Calculate points
|
|
||||||
participant = assignment.participant
|
participant = assignment.participant
|
||||||
challenge = assignment.challenge
|
|
||||||
|
|
||||||
# Get marathon_id for activity and event check
|
# Handle playthrough completion
|
||||||
result = await db.execute(
|
if assignment.is_playthrough:
|
||||||
select(Challenge).options(selectinload(Challenge.game)).where(Challenge.id == challenge.id)
|
game = assignment.game
|
||||||
)
|
marathon_id = game.marathon_id
|
||||||
full_challenge = result.scalar_one()
|
base_points = game.playthrough_points
|
||||||
marathon_id = full_challenge.game.marathon_id
|
|
||||||
|
# No events for playthrough
|
||||||
|
total_points, streak_bonus, _ = points_service.calculate_completion_points(
|
||||||
|
base_points, participant.current_streak, None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate bonus points from completed bonus assignments
|
||||||
|
bonus_points = sum(
|
||||||
|
ba.points_earned for ba in assignment.bonus_assignments
|
||||||
|
if ba.status == BonusAssignmentStatus.COMPLETED.value
|
||||||
|
)
|
||||||
|
total_points += bonus_points
|
||||||
|
|
||||||
|
# Update assignment
|
||||||
|
assignment.status = AssignmentStatus.COMPLETED.value
|
||||||
|
assignment.points_earned = total_points
|
||||||
|
assignment.streak_at_completion = participant.current_streak + 1
|
||||||
|
assignment.completed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
# Update participant
|
||||||
|
participant.total_points += total_points
|
||||||
|
participant.current_streak += 1
|
||||||
|
participant.drop_count = 0
|
||||||
|
|
||||||
|
# Check if this is a redo of a previously disputed assignment
|
||||||
|
is_redo = (
|
||||||
|
assignment.dispute is not None and
|
||||||
|
assignment.dispute.status == DisputeStatus.RESOLVED_INVALID.value
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log activity
|
||||||
|
activity_data = {
|
||||||
|
"assignment_id": assignment.id,
|
||||||
|
"game": game.title,
|
||||||
|
"is_playthrough": True,
|
||||||
|
"points": total_points,
|
||||||
|
"base_points": base_points,
|
||||||
|
"bonus_points": bonus_points,
|
||||||
|
"streak": participant.current_streak,
|
||||||
|
}
|
||||||
|
if is_redo:
|
||||||
|
activity_data["is_redo"] = True
|
||||||
|
|
||||||
|
activity = Activity(
|
||||||
|
marathon_id=marathon_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
type=ActivityType.COMPLETE.value,
|
||||||
|
data=activity_data,
|
||||||
|
)
|
||||||
|
db.add(activity)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Check for returned assignments
|
||||||
|
returned_assignment = await get_oldest_returned_assignment(db, participant.id)
|
||||||
|
if returned_assignment:
|
||||||
|
await activate_returned_assignment(db, returned_assignment)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return CompleteResult(
|
||||||
|
points_earned=total_points,
|
||||||
|
streak_bonus=streak_bonus,
|
||||||
|
total_points=participant.total_points,
|
||||||
|
new_streak=participant.current_streak,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Regular challenge completion
|
||||||
|
challenge = assignment.challenge
|
||||||
|
marathon_id = challenge.game.marathon_id
|
||||||
|
|
||||||
# Check active event for point multipliers
|
# Check active event for point multipliers
|
||||||
active_event = await event_service.get_active_event(db, marathon_id)
|
active_event = await event_service.get_active_event(db, marathon_id)
|
||||||
|
|
||||||
# For jackpot: use the event_type stored in assignment (since event may be over)
|
# For jackpot: use the event_type stored in assignment (since event may be over)
|
||||||
# For other events: use the currently active event
|
|
||||||
effective_event = active_event
|
effective_event = active_event
|
||||||
|
|
||||||
# Handle assignment-level event types (jackpot)
|
# Handle assignment-level event types (jackpot)
|
||||||
if assignment.event_type == EventType.JACKPOT.value:
|
if assignment.event_type == EventType.JACKPOT.value:
|
||||||
# Create a mock event object for point calculation
|
|
||||||
class MockEvent:
|
class MockEvent:
|
||||||
def __init__(self, event_type):
|
def __init__(self, event_type):
|
||||||
self.type = event_type
|
self.type = event_type
|
||||||
@@ -386,18 +678,25 @@ async def complete_assignment(
|
|||||||
# Update participant
|
# Update participant
|
||||||
participant.total_points += total_points
|
participant.total_points += total_points
|
||||||
participant.current_streak += 1
|
participant.current_streak += 1
|
||||||
participant.drop_count = 0 # Reset drop counter on success
|
participant.drop_count = 0
|
||||||
|
|
||||||
|
# Check if this is a redo of a previously disputed assignment
|
||||||
|
is_redo = (
|
||||||
|
assignment.dispute is not None and
|
||||||
|
assignment.dispute.status == DisputeStatus.RESOLVED_INVALID.value
|
||||||
|
)
|
||||||
|
|
||||||
# Log activity
|
# Log activity
|
||||||
activity_data = {
|
activity_data = {
|
||||||
"assignment_id": assignment.id,
|
"assignment_id": assignment.id,
|
||||||
"game": full_challenge.game.title,
|
"game": challenge.game.title,
|
||||||
"challenge": challenge.title,
|
"challenge": challenge.title,
|
||||||
"difficulty": challenge.difficulty,
|
"difficulty": challenge.difficulty,
|
||||||
"points": total_points,
|
"points": total_points,
|
||||||
"streak": participant.current_streak,
|
"streak": participant.current_streak,
|
||||||
}
|
}
|
||||||
# Log event info (use assignment's event_type for jackpot, active_event for others)
|
if is_redo:
|
||||||
|
activity_data["is_redo"] = True
|
||||||
if assignment.event_type == EventType.JACKPOT.value:
|
if assignment.event_type == EventType.JACKPOT.value:
|
||||||
activity_data["event_type"] = assignment.event_type
|
activity_data["event_type"] = assignment.event_type
|
||||||
activity_data["event_bonus"] = event_bonus
|
activity_data["event_bonus"] = event_bonus
|
||||||
@@ -418,7 +717,6 @@ async def complete_assignment(
|
|||||||
# If common enemy event auto-closed, log the event end with winners
|
# If common enemy event auto-closed, log the event end with winners
|
||||||
if common_enemy_closed and common_enemy_winners:
|
if common_enemy_closed and common_enemy_winners:
|
||||||
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
|
from app.schemas.event import EVENT_INFO, COMMON_ENEMY_BONUSES
|
||||||
# Load winner nicknames
|
|
||||||
winner_user_ids = [w["user_id"] for w in common_enemy_winners]
|
winner_user_ids = [w["user_id"] for w in common_enemy_winners]
|
||||||
users_result = await db.execute(
|
users_result = await db.execute(
|
||||||
select(User).where(User.id.in_(winner_user_ids))
|
select(User).where(User.id.in_(winner_user_ids))
|
||||||
@@ -438,7 +736,7 @@ async def complete_assignment(
|
|||||||
|
|
||||||
event_end_activity = Activity(
|
event_end_activity = Activity(
|
||||||
marathon_id=marathon_id,
|
marathon_id=marathon_id,
|
||||||
user_id=current_user.id, # Last completer triggers the close
|
user_id=current_user.id,
|
||||||
type=ActivityType.EVENT_END.value,
|
type=ActivityType.EVENT_END.value,
|
||||||
data={
|
data={
|
||||||
"event_type": EventType.COMMON_ENEMY.value,
|
"event_type": EventType.COMMON_ENEMY.value,
|
||||||
@@ -451,7 +749,7 @@ async def complete_assignment(
|
|||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Check for returned assignments and activate the oldest one
|
# Check for returned assignments
|
||||||
returned_assignment = await get_oldest_returned_assignment(db, participant.id)
|
returned_assignment = await get_oldest_returned_assignment(db, participant.id)
|
||||||
if returned_assignment:
|
if returned_assignment:
|
||||||
await activate_returned_assignment(db, returned_assignment)
|
await activate_returned_assignment(db, returned_assignment)
|
||||||
@@ -469,12 +767,14 @@ async def complete_assignment(
|
|||||||
@router.post("/assignments/{assignment_id}/drop", response_model=DropResult)
|
@router.post("/assignments/{assignment_id}/drop", response_model=DropResult)
|
||||||
async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession):
|
async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
"""Drop current assignment"""
|
"""Drop current assignment"""
|
||||||
# Get assignment
|
# Get assignment with all needed relationships
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Assignment)
|
select(Assignment)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Assignment.participant),
|
selectinload(Assignment.participant),
|
||||||
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
selectinload(Assignment.game), # For playthrough
|
||||||
|
selectinload(Assignment.bonus_assignments), # For resetting bonuses on drop
|
||||||
)
|
)
|
||||||
.where(Assignment.id == assignment_id)
|
.where(Assignment.id == assignment_id)
|
||||||
)
|
)
|
||||||
@@ -490,6 +790,61 @@ async def drop_assignment(assignment_id: int, current_user: CurrentUser, db: DbS
|
|||||||
raise HTTPException(status_code=400, detail="Assignment is not active")
|
raise HTTPException(status_code=400, detail="Assignment is not active")
|
||||||
|
|
||||||
participant = assignment.participant
|
participant = assignment.participant
|
||||||
|
|
||||||
|
# Handle playthrough drop
|
||||||
|
if assignment.is_playthrough:
|
||||||
|
game = assignment.game
|
||||||
|
marathon_id = game.marathon_id
|
||||||
|
|
||||||
|
# No events for playthrough
|
||||||
|
penalty = points_service.calculate_drop_penalty(
|
||||||
|
participant.drop_count, game.playthrough_points, None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update assignment
|
||||||
|
assignment.status = AssignmentStatus.DROPPED.value
|
||||||
|
assignment.completed_at = datetime.utcnow()
|
||||||
|
|
||||||
|
# Reset all bonus assignments (lose any completed bonuses)
|
||||||
|
completed_bonuses_count = 0
|
||||||
|
for ba in assignment.bonus_assignments:
|
||||||
|
if ba.status == BonusAssignmentStatus.COMPLETED.value:
|
||||||
|
completed_bonuses_count += 1
|
||||||
|
ba.status = BonusAssignmentStatus.PENDING.value
|
||||||
|
ba.proof_path = None
|
||||||
|
ba.proof_url = None
|
||||||
|
ba.proof_comment = None
|
||||||
|
ba.points_earned = 0
|
||||||
|
ba.completed_at = None
|
||||||
|
|
||||||
|
# Update participant
|
||||||
|
participant.total_points = max(0, participant.total_points - penalty)
|
||||||
|
participant.current_streak = 0
|
||||||
|
participant.drop_count += 1
|
||||||
|
|
||||||
|
# Log activity
|
||||||
|
activity = Activity(
|
||||||
|
marathon_id=marathon_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
type=ActivityType.DROP.value,
|
||||||
|
data={
|
||||||
|
"game": game.title,
|
||||||
|
"is_playthrough": True,
|
||||||
|
"penalty": penalty,
|
||||||
|
"lost_bonuses": completed_bonuses_count,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.add(activity)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return DropResult(
|
||||||
|
penalty=penalty,
|
||||||
|
total_points=participant.total_points,
|
||||||
|
new_drop_count=participant.drop_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Regular challenge drop
|
||||||
marathon_id = assignment.challenge.game.marathon_id
|
marathon_id = assignment.challenge.game.marathon_id
|
||||||
|
|
||||||
# Check active event for free drops (double_risk)
|
# Check active event for free drops (double_risk)
|
||||||
@@ -550,7 +905,9 @@ async def get_my_history(
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Assignment)
|
select(Assignment)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
selectinload(Assignment.game), # For playthrough
|
||||||
|
selectinload(Assignment.bonus_assignments).selectinload(BonusAssignment.challenge), # For playthrough bonuses
|
||||||
)
|
)
|
||||||
.where(Assignment.participant_id == participant.id)
|
.where(Assignment.participant_id == participant.id)
|
||||||
.order_by(Assignment.started_at.desc())
|
.order_by(Assignment.started_at.desc())
|
||||||
@@ -559,34 +916,89 @@ async def get_my_history(
|
|||||||
)
|
)
|
||||||
assignments = result.scalars().all()
|
assignments = result.scalars().all()
|
||||||
|
|
||||||
return [
|
responses = []
|
||||||
AssignmentResponse(
|
for a in assignments:
|
||||||
id=a.id,
|
if a.is_playthrough:
|
||||||
challenge=ChallengeResponse(
|
# Playthrough assignment
|
||||||
id=a.challenge.id,
|
game = a.game
|
||||||
title=a.challenge.title,
|
from app.schemas.assignment import BonusAssignmentResponse
|
||||||
description=a.challenge.description,
|
bonus_responses = [
|
||||||
type=a.challenge.type,
|
BonusAssignmentResponse(
|
||||||
difficulty=a.challenge.difficulty,
|
id=ba.id,
|
||||||
points=a.challenge.points,
|
challenge=ChallengeResponse(
|
||||||
estimated_time=a.challenge.estimated_time,
|
id=ba.challenge.id,
|
||||||
proof_type=a.challenge.proof_type,
|
title=ba.challenge.title,
|
||||||
proof_hint=a.challenge.proof_hint,
|
description=ba.challenge.description,
|
||||||
game=GameShort(
|
type=ba.challenge.type,
|
||||||
id=a.challenge.game.id,
|
difficulty=ba.challenge.difficulty,
|
||||||
title=a.challenge.game.title,
|
points=ba.challenge.points,
|
||||||
cover_url=None
|
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,
|
status=a.status,
|
||||||
created_at=a.challenge.created_at,
|
proof_url=storage_service.get_url(a.proof_path, "proofs") if a.proof_path else a.proof_url,
|
||||||
),
|
proof_comment=a.proof_comment,
|
||||||
status=a.status,
|
points_earned=a.points_earned,
|
||||||
proof_url=storage_service.get_url(a.proof_path, "proofs") if a.proof_path else a.proof_url,
|
streak_at_completion=a.streak_at_completion,
|
||||||
proof_comment=a.proof_comment,
|
started_at=a.started_at,
|
||||||
points_earned=a.points_earned,
|
completed_at=a.completed_at,
|
||||||
streak_at_completion=a.streak_at_completion,
|
bonus_challenges=bonus_responses,
|
||||||
started_at=a.started_at,
|
))
|
||||||
completed_at=a.completed_at,
|
else:
|
||||||
)
|
# Regular challenge assignment
|
||||||
for a in assignments
|
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
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class Settings(BaseSettings):
|
|||||||
# App
|
# App
|
||||||
APP_NAME: str = "Game Marathon"
|
APP_NAME: str = "Game Marathon"
|
||||||
DEBUG: bool = False
|
DEBUG: bool = False
|
||||||
|
RATE_LIMIT_ENABLED: bool = True # Set to False to disable rate limiting
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL: str = "postgresql+asyncpg://marathon:marathon@localhost:5432/marathon"
|
DATABASE_URL: str = "postgresql+asyncpg://marathon:marathon@localhost:5432/marathon"
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
from slowapi import Limiter
|
from slowapi import Limiter
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
# Rate limiter using client IP address as key
|
# Rate limiter using client IP address as key
|
||||||
limiter = Limiter(key_func=get_remote_address)
|
# Can be disabled via RATE_LIMIT_ENABLED=false in .env
|
||||||
|
limiter = Limiter(
|
||||||
|
key_func=get_remote_address,
|
||||||
|
enabled=settings.RATE_LIMIT_ENABLED
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
from app.models.user import User, UserRole
|
from app.models.user import User, UserRole
|
||||||
from app.models.marathon import Marathon, MarathonStatus, GameProposalMode
|
from app.models.marathon import Marathon, MarathonStatus, GameProposalMode
|
||||||
from app.models.participant import Participant, ParticipantRole
|
from app.models.participant import Participant, ParticipantRole
|
||||||
from app.models.game import Game, GameStatus
|
from app.models.game import Game, GameStatus, GameType
|
||||||
from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType
|
from app.models.challenge import Challenge, ChallengeType, Difficulty, ProofType
|
||||||
from app.models.assignment import Assignment, AssignmentStatus
|
from app.models.assignment import Assignment, AssignmentStatus
|
||||||
|
from app.models.bonus_assignment import BonusAssignment, BonusAssignmentStatus
|
||||||
|
from app.models.assignment_proof import AssignmentProof, BonusAssignmentProof
|
||||||
from app.models.activity import Activity, ActivityType
|
from app.models.activity import Activity, ActivityType
|
||||||
from app.models.event import Event, EventType
|
from app.models.event import Event, EventType
|
||||||
from app.models.swap_request import SwapRequest, SwapRequestStatus
|
from app.models.swap_request import SwapRequest, SwapRequestStatus
|
||||||
@@ -22,12 +24,17 @@ __all__ = [
|
|||||||
"ParticipantRole",
|
"ParticipantRole",
|
||||||
"Game",
|
"Game",
|
||||||
"GameStatus",
|
"GameStatus",
|
||||||
|
"GameType",
|
||||||
"Challenge",
|
"Challenge",
|
||||||
"ChallengeType",
|
"ChallengeType",
|
||||||
"Difficulty",
|
"Difficulty",
|
||||||
"ProofType",
|
"ProofType",
|
||||||
"Assignment",
|
"Assignment",
|
||||||
"AssignmentStatus",
|
"AssignmentStatus",
|
||||||
|
"BonusAssignment",
|
||||||
|
"BonusAssignmentStatus",
|
||||||
|
"AssignmentProof",
|
||||||
|
"BonusAssignmentProof",
|
||||||
"Activity",
|
"Activity",
|
||||||
"ActivityType",
|
"ActivityType",
|
||||||
"Event",
|
"Event",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class AdminActionType(str, Enum):
|
|||||||
USER_UNBAN = "user_unban"
|
USER_UNBAN = "user_unban"
|
||||||
USER_AUTO_UNBAN = "user_auto_unban" # System automatic unban
|
USER_AUTO_UNBAN = "user_auto_unban" # System automatic unban
|
||||||
USER_ROLE_CHANGE = "user_role_change"
|
USER_ROLE_CHANGE = "user_role_change"
|
||||||
|
USER_PASSWORD_RESET = "user_password_reset"
|
||||||
|
|
||||||
# Marathon actions
|
# Marathon actions
|
||||||
MARATHON_FORCE_FINISH = "marathon_force_finish"
|
MARATHON_FORCE_FINISH = "marathon_force_finish"
|
||||||
@@ -29,6 +30,10 @@ class AdminActionType(str, Enum):
|
|||||||
ADMIN_2FA_SUCCESS = "admin_2fa_success"
|
ADMIN_2FA_SUCCESS = "admin_2fa_success"
|
||||||
ADMIN_2FA_FAIL = "admin_2fa_fail"
|
ADMIN_2FA_FAIL = "admin_2fa_fail"
|
||||||
|
|
||||||
|
# Dispute actions
|
||||||
|
DISPUTE_RESOLVE_VALID = "dispute_resolve_valid"
|
||||||
|
DISPUTE_RESOLVE_INVALID = "dispute_resolve_invalid"
|
||||||
|
|
||||||
|
|
||||||
class AdminLog(Base):
|
class AdminLog(Base):
|
||||||
__tablename__ = "admin_logs"
|
__tablename__ = "admin_logs"
|
||||||
|
|||||||
@@ -18,8 +18,12 @@ class Assignment(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"), index=True)
|
participant_id: Mapped[int] = mapped_column(ForeignKey("participants.id", ondelete="CASCADE"), index=True)
|
||||||
challenge_id: Mapped[int] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"))
|
challenge_id: Mapped[int | None] = mapped_column(ForeignKey("challenges.id", ondelete="CASCADE"), nullable=True) # None для playthrough
|
||||||
status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value)
|
status: Mapped[str] = mapped_column(String(20), default=AssignmentStatus.ACTIVE.value)
|
||||||
|
|
||||||
|
# Для прохождений (playthrough)
|
||||||
|
game_id: Mapped[int | None] = mapped_column(ForeignKey("games.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||||
|
is_playthrough: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
event_type: Mapped[str | None] = mapped_column(String(30), nullable=True) # Event type when assignment was created
|
event_type: Mapped[str | None] = mapped_column(String(30), nullable=True) # Event type when assignment was created
|
||||||
is_event_assignment: Mapped[bool] = mapped_column(Boolean, default=False, index=True) # True for Common Enemy assignments
|
is_event_assignment: Mapped[bool] = mapped_column(Boolean, default=False, index=True) # True for Common Enemy assignments
|
||||||
event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id", ondelete="SET NULL"), nullable=True, index=True) # Link to event
|
event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id", ondelete="SET NULL"), nullable=True, index=True) # Link to event
|
||||||
@@ -33,6 +37,9 @@ class Assignment(Base):
|
|||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments")
|
participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments")
|
||||||
challenge: Mapped["Challenge"] = relationship("Challenge", back_populates="assignments")
|
challenge: Mapped["Challenge | None"] = relationship("Challenge", back_populates="assignments")
|
||||||
|
game: Mapped["Game | None"] = relationship("Game", back_populates="playthrough_assignments", foreign_keys=[game_id])
|
||||||
event: Mapped["Event | None"] = relationship("Event", back_populates="assignments")
|
event: Mapped["Event | None"] = relationship("Event", back_populates="assignments")
|
||||||
dispute: Mapped["Dispute | None"] = relationship("Dispute", back_populates="assignment", uselist=False, cascade="all, delete-orphan", passive_deletes=True)
|
dispute: Mapped["Dispute | None"] = relationship("Dispute", back_populates="assignment", uselist=False, cascade="all, delete-orphan", passive_deletes=True)
|
||||||
|
bonus_assignments: Mapped[list["BonusAssignment"]] = relationship("BonusAssignment", back_populates="main_assignment", cascade="all, delete-orphan")
|
||||||
|
proof_files: Mapped[list["AssignmentProof"]] = relationship("AssignmentProof", back_populates="assignment", cascade="all, delete-orphan", order_by="AssignmentProof.order_index")
|
||||||
|
|||||||
47
backend/app/models/assignment_proof.py
Normal file
47
backend/app/models/assignment_proof.py
Normal 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"
|
||||||
|
)
|
||||||
54
backend/app/models/bonus_assignment.py
Normal file
54
backend/app/models/bonus_assignment.py
Normal 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"
|
||||||
|
)
|
||||||
@@ -8,16 +8,19 @@ from app.core.database import Base
|
|||||||
|
|
||||||
class DisputeStatus(str, Enum):
|
class DisputeStatus(str, Enum):
|
||||||
OPEN = "open"
|
OPEN = "open"
|
||||||
|
PENDING_ADMIN = "pending_admin" # Voting ended, waiting for admin decision
|
||||||
RESOLVED_VALID = "valid"
|
RESOLVED_VALID = "valid"
|
||||||
RESOLVED_INVALID = "invalid"
|
RESOLVED_INVALID = "invalid"
|
||||||
|
|
||||||
|
|
||||||
class Dispute(Base):
|
class Dispute(Base):
|
||||||
"""Dispute against a completed assignment's proof"""
|
"""Dispute against a completed assignment's or bonus assignment's proof"""
|
||||||
__tablename__ = "disputes"
|
__tablename__ = "disputes"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
assignment_id: Mapped[int] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), unique=True, index=True)
|
# Either assignment_id OR bonus_assignment_id should be set (not both)
|
||||||
|
assignment_id: Mapped[int | None] = mapped_column(ForeignKey("assignments.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||||
|
bonus_assignment_id: Mapped[int | None] = mapped_column(ForeignKey("bonus_assignments.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||||
raised_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
raised_by_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
status: Mapped[str] = mapped_column(String(20), default=DisputeStatus.OPEN.value)
|
status: Mapped[str] = mapped_column(String(20), default=DisputeStatus.OPEN.value)
|
||||||
@@ -26,6 +29,7 @@ class Dispute(Base):
|
|||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
assignment: Mapped["Assignment"] = relationship("Assignment", back_populates="dispute")
|
assignment: Mapped["Assignment"] = relationship("Assignment", back_populates="dispute")
|
||||||
|
bonus_assignment: Mapped["BonusAssignment"] = relationship("BonusAssignment", back_populates="dispute")
|
||||||
raised_by: Mapped["User"] = relationship("User", foreign_keys=[raised_by_id])
|
raised_by: Mapped["User"] = relationship("User", foreign_keys=[raised_by_id])
|
||||||
comments: Mapped[list["DisputeComment"]] = relationship("DisputeComment", back_populates="dispute", cascade="all, delete-orphan")
|
comments: Mapped[list["DisputeComment"]] = relationship("DisputeComment", back_populates="dispute", cascade="all, delete-orphan")
|
||||||
votes: Mapped[list["DisputeVote"]] = relationship("DisputeVote", back_populates="dispute", cascade="all, delete-orphan")
|
votes: Mapped[list["DisputeVote"]] = relationship("DisputeVote", back_populates="dispute", cascade="all, delete-orphan")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from sqlalchemy import String, DateTime, ForeignKey, Text
|
from sqlalchemy import String, DateTime, ForeignKey, Text, Integer
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -12,6 +12,11 @@ class GameStatus(str, Enum):
|
|||||||
REJECTED = "rejected" # Отклонена
|
REJECTED = "rejected" # Отклонена
|
||||||
|
|
||||||
|
|
||||||
|
class GameType(str, Enum):
|
||||||
|
PLAYTHROUGH = "playthrough" # Прохождение игры
|
||||||
|
CHALLENGES = "challenges" # Челленджи
|
||||||
|
|
||||||
|
|
||||||
class Game(Base):
|
class Game(Base):
|
||||||
__tablename__ = "games"
|
__tablename__ = "games"
|
||||||
|
|
||||||
@@ -26,6 +31,15 @@ class Game(Base):
|
|||||||
approved_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
approved_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Тип игры
|
||||||
|
game_type: Mapped[str] = mapped_column(String(20), default=GameType.CHALLENGES.value, nullable=False)
|
||||||
|
|
||||||
|
# Поля для типа "Прохождение" (заполняются только для playthrough)
|
||||||
|
playthrough_points: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
playthrough_description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
playthrough_proof_type: Mapped[str | None] = mapped_column(String(20), nullable=True) # screenshot, video, steam
|
||||||
|
playthrough_proof_hint: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="games")
|
marathon: Mapped["Marathon"] = relationship("Marathon", back_populates="games")
|
||||||
proposed_by: Mapped["User"] = relationship(
|
proposed_by: Mapped["User"] = relationship(
|
||||||
@@ -43,6 +57,12 @@ class Game(Base):
|
|||||||
back_populates="game",
|
back_populates="game",
|
||||||
cascade="all, delete-orphan"
|
cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
# Assignments для прохождений (playthrough)
|
||||||
|
playthrough_assignments: Mapped[list["Assignment"]] = relationship(
|
||||||
|
"Assignment",
|
||||||
|
back_populates="game",
|
||||||
|
foreign_keys="Assignment.game_id"
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_approved(self) -> bool:
|
def is_approved(self) -> bool:
|
||||||
@@ -51,3 +71,11 @@ class Game(Base):
|
|||||||
@property
|
@property
|
||||||
def is_pending(self) -> bool:
|
def is_pending(self) -> bool:
|
||||||
return self.status == GameStatus.PENDING.value
|
return self.status == GameStatus.PENDING.value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_playthrough(self) -> bool:
|
||||||
|
return self.game_type == GameType.PLAYTHROUGH.value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_challenges(self) -> bool:
|
||||||
|
return self.game_type == GameType.CHALLENGES.value
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ class Marathon(Base):
|
|||||||
start_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
start_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
end_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
end_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
auto_events_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
auto_events_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
cover_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
cover_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ from app.schemas.assignment import (
|
|||||||
CompleteResult,
|
CompleteResult,
|
||||||
DropResult,
|
DropResult,
|
||||||
EventAssignmentResponse,
|
EventAssignmentResponse,
|
||||||
|
BonusAssignmentResponse,
|
||||||
|
CompleteBonusAssignment,
|
||||||
|
BonusCompleteResult,
|
||||||
|
AvailableGamesCount,
|
||||||
)
|
)
|
||||||
from app.schemas.activity import (
|
from app.schemas.activity import (
|
||||||
ActivityResponse,
|
ActivityResponse,
|
||||||
@@ -83,6 +87,7 @@ from app.schemas.dispute import (
|
|||||||
)
|
)
|
||||||
from app.schemas.admin import (
|
from app.schemas.admin import (
|
||||||
BanUserRequest,
|
BanUserRequest,
|
||||||
|
AdminResetPasswordRequest,
|
||||||
AdminUserResponse,
|
AdminUserResponse,
|
||||||
AdminLogResponse,
|
AdminLogResponse,
|
||||||
AdminLogsListResponse,
|
AdminLogsListResponse,
|
||||||
@@ -143,6 +148,10 @@ __all__ = [
|
|||||||
"CompleteResult",
|
"CompleteResult",
|
||||||
"DropResult",
|
"DropResult",
|
||||||
"EventAssignmentResponse",
|
"EventAssignmentResponse",
|
||||||
|
"BonusAssignmentResponse",
|
||||||
|
"CompleteBonusAssignment",
|
||||||
|
"BonusCompleteResult",
|
||||||
|
"AvailableGamesCount",
|
||||||
# Activity
|
# Activity
|
||||||
"ActivityResponse",
|
"ActivityResponse",
|
||||||
"FeedResponse",
|
"FeedResponse",
|
||||||
@@ -175,6 +184,7 @@ __all__ = [
|
|||||||
"ReturnedAssignmentResponse",
|
"ReturnedAssignmentResponse",
|
||||||
# Admin
|
# Admin
|
||||||
"BanUserRequest",
|
"BanUserRequest",
|
||||||
|
"AdminResetPasswordRequest",
|
||||||
"AdminUserResponse",
|
"AdminUserResponse",
|
||||||
"AdminLogResponse",
|
"AdminLogResponse",
|
||||||
"AdminLogsListResponse",
|
"AdminLogsListResponse",
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ class BanUserRequest(BaseModel):
|
|||||||
banned_until: datetime | None = None # None = permanent ban
|
banned_until: datetime | None = None # None = permanent ban
|
||||||
|
|
||||||
|
|
||||||
|
class AdminResetPasswordRequest(BaseModel):
|
||||||
|
new_password: str = Field(..., min_length=6, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
class AdminUserResponse(BaseModel):
|
class AdminUserResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
login: str
|
login: str
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.schemas.game import GameResponse
|
from app.schemas.game import GameResponse, GameShort, PlaythroughInfo
|
||||||
from app.schemas.challenge import ChallengeResponse
|
from app.schemas.challenge import ChallengeResponse
|
||||||
|
|
||||||
|
|
||||||
|
class ProofFileResponse(BaseModel):
|
||||||
|
"""Информация о файле-доказательстве"""
|
||||||
|
id: int
|
||||||
|
file_type: str # image или video
|
||||||
|
order_index: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
class AssignmentBase(BaseModel):
|
class AssignmentBase(BaseModel):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -14,9 +25,28 @@ class CompleteAssignment(BaseModel):
|
|||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class AssignmentResponse(BaseModel):
|
class BonusAssignmentResponse(BaseModel):
|
||||||
|
"""Ответ с информацией о бонусном челлендже"""
|
||||||
id: int
|
id: int
|
||||||
challenge: ChallengeResponse
|
challenge: ChallengeResponse
|
||||||
|
status: str # pending, completed
|
||||||
|
proof_url: str | None = None
|
||||||
|
proof_image_url: str | None = None # Legacy, for backward compatibility
|
||||||
|
proof_files: list[ProofFileResponse] = [] # Multiple uploaded files
|
||||||
|
proof_comment: str | None = None
|
||||||
|
points_earned: int = 0
|
||||||
|
completed_at: datetime | None = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AssignmentResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
challenge: ChallengeResponse | None # None для playthrough
|
||||||
|
game: GameShort | None = None # Заполняется для playthrough
|
||||||
|
is_playthrough: bool = False
|
||||||
|
playthrough_info: PlaythroughInfo | None = None # Заполняется для playthrough
|
||||||
status: str
|
status: str
|
||||||
proof_url: str | None = None
|
proof_url: str | None = None
|
||||||
proof_comment: str | None = None
|
proof_comment: str | None = None
|
||||||
@@ -25,6 +55,7 @@ class AssignmentResponse(BaseModel):
|
|||||||
started_at: datetime
|
started_at: datetime
|
||||||
completed_at: datetime | None = None
|
completed_at: datetime | None = None
|
||||||
drop_penalty: int = 0 # Calculated penalty if dropped
|
drop_penalty: int = 0 # Calculated penalty if dropped
|
||||||
|
bonus_challenges: list[BonusAssignmentResponse] = [] # Для playthrough
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -33,7 +64,10 @@ class AssignmentResponse(BaseModel):
|
|||||||
class SpinResult(BaseModel):
|
class SpinResult(BaseModel):
|
||||||
assignment_id: int
|
assignment_id: int
|
||||||
game: GameResponse
|
game: GameResponse
|
||||||
challenge: ChallengeResponse
|
challenge: ChallengeResponse | None # None для playthrough
|
||||||
|
is_playthrough: bool = False
|
||||||
|
playthrough_info: PlaythroughInfo | None = None # Заполняется для playthrough
|
||||||
|
bonus_challenges: list[ChallengeResponse] = [] # Для playthrough - список доступных бонусных челленджей
|
||||||
can_drop: bool
|
can_drop: bool
|
||||||
drop_penalty: int
|
drop_penalty: int
|
||||||
|
|
||||||
@@ -60,3 +94,22 @@ class EventAssignmentResponse(BaseModel):
|
|||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class CompleteBonusAssignment(BaseModel):
|
||||||
|
"""Запрос на завершение бонусного челленджа"""
|
||||||
|
proof_url: str | None = None
|
||||||
|
comment: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BonusCompleteResult(BaseModel):
|
||||||
|
"""Результат завершения бонусного челленджа"""
|
||||||
|
bonus_assignment_id: int
|
||||||
|
points_earned: int
|
||||||
|
total_bonus_points: int # Сумма очков за все бонусные челленджи
|
||||||
|
|
||||||
|
|
||||||
|
class AvailableGamesCount(BaseModel):
|
||||||
|
"""Количество доступных игр для спина"""
|
||||||
|
available: int
|
||||||
|
total: int
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class ChallengeBase(BaseModel):
|
|||||||
description: str = Field(..., min_length=1)
|
description: str = Field(..., min_length=1)
|
||||||
type: ChallengeType
|
type: ChallengeType
|
||||||
difficulty: Difficulty
|
difficulty: Difficulty
|
||||||
points: int = Field(..., ge=1, le=500)
|
points: int = Field(..., ge=1, le=1000)
|
||||||
estimated_time: int | None = Field(None, ge=1) # minutes
|
estimated_time: int | None = Field(None, ge=1) # minutes
|
||||||
proof_type: ProofType
|
proof_type: ProofType
|
||||||
proof_hint: str | None = None
|
proof_hint: str | None = None
|
||||||
@@ -34,7 +34,7 @@ class ChallengeUpdate(BaseModel):
|
|||||||
description: str | None = None
|
description: str | None = None
|
||||||
type: ChallengeType | None = None
|
type: ChallengeType | None = None
|
||||||
difficulty: Difficulty | None = None
|
difficulty: Difficulty | None = None
|
||||||
points: int | None = Field(None, ge=1, le=500)
|
points: int | None = Field(None, ge=1, le=1000)
|
||||||
estimated_time: int | None = None
|
estimated_time: int | None = None
|
||||||
proof_type: ProofType | None = None
|
proof_type: ProofType | None = None
|
||||||
proof_hint: str | None = None
|
proof_hint: str | None = None
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.schemas.user import UserPublic
|
from app.schemas.user import UserPublic
|
||||||
from app.schemas.challenge import ChallengeResponse
|
from app.schemas.challenge import ChallengeResponse, GameShort
|
||||||
|
from app.schemas.assignment import ProofFileResponse
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.schemas.game import PlaythroughInfo
|
||||||
|
from app.schemas.assignment import BonusAssignmentResponse
|
||||||
|
|
||||||
|
|
||||||
class DisputeCreate(BaseModel):
|
class DisputeCreate(BaseModel):
|
||||||
@@ -63,11 +69,15 @@ class DisputeResponse(BaseModel):
|
|||||||
class AssignmentDetailResponse(BaseModel):
|
class AssignmentDetailResponse(BaseModel):
|
||||||
"""Detailed assignment information with proofs and dispute"""
|
"""Detailed assignment information with proofs and dispute"""
|
||||||
id: int
|
id: int
|
||||||
challenge: ChallengeResponse
|
challenge: ChallengeResponse | None # None for playthrough
|
||||||
|
game: GameShort | None = None # For playthrough
|
||||||
|
is_playthrough: bool = False
|
||||||
|
playthrough_info: dict | None = None # For playthrough (description, points, proof_type, proof_hint)
|
||||||
participant: UserPublic
|
participant: UserPublic
|
||||||
status: str
|
status: str
|
||||||
proof_url: str | None # External URL (YouTube, etc.)
|
proof_url: str | None # External URL (YouTube, etc.)
|
||||||
proof_image_url: str | None # Uploaded file URL
|
proof_image_url: str | None # Uploaded file URL (legacy, for backward compatibility)
|
||||||
|
proof_files: list[ProofFileResponse] = [] # Multiple uploaded files
|
||||||
proof_comment: str | None
|
proof_comment: str | None
|
||||||
points_earned: int
|
points_earned: int
|
||||||
streak_at_completion: int | None
|
streak_at_completion: int | None
|
||||||
@@ -75,6 +85,7 @@ class AssignmentDetailResponse(BaseModel):
|
|||||||
completed_at: datetime | None
|
completed_at: datetime | None
|
||||||
can_dispute: bool # True if <24h since completion and not own assignment
|
can_dispute: bool # True if <24h since completion and not own assignment
|
||||||
dispute: DisputeResponse | None
|
dispute: DisputeResponse | None
|
||||||
|
bonus_challenges: list[dict] | None = None # For playthrough
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -83,7 +94,11 @@ class AssignmentDetailResponse(BaseModel):
|
|||||||
class ReturnedAssignmentResponse(BaseModel):
|
class ReturnedAssignmentResponse(BaseModel):
|
||||||
"""Returned assignment that needs to be redone"""
|
"""Returned assignment that needs to be redone"""
|
||||||
id: int
|
id: int
|
||||||
challenge: ChallengeResponse
|
challenge: ChallengeResponse | None = None # For challenge assignments
|
||||||
|
is_playthrough: bool = False
|
||||||
|
game_id: int | None = None # For playthrough assignments
|
||||||
|
game_title: str | None = None
|
||||||
|
game_cover_url: str | None = None
|
||||||
original_completed_at: datetime
|
original_completed_at: datetime
|
||||||
dispute_reason: str
|
dispute_reason: str
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pydantic import BaseModel, Field, HttpUrl
|
from typing import Self
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
from app.models.game import GameType
|
||||||
|
from app.models.challenge import ProofType
|
||||||
from app.schemas.user import UserPublic
|
from app.schemas.user import UserPublic
|
||||||
|
|
||||||
|
|
||||||
@@ -13,17 +16,48 @@ class GameBase(BaseModel):
|
|||||||
class GameCreate(GameBase):
|
class GameCreate(GameBase):
|
||||||
cover_url: str | None = None
|
cover_url: str | None = None
|
||||||
|
|
||||||
|
# Тип игры
|
||||||
|
game_type: GameType = GameType.CHALLENGES
|
||||||
|
|
||||||
|
# Поля для типа "Прохождение"
|
||||||
|
playthrough_points: int | None = Field(None, ge=1, le=1000)
|
||||||
|
playthrough_description: str | None = None
|
||||||
|
playthrough_proof_type: ProofType | None = None
|
||||||
|
playthrough_proof_hint: str | None = None
|
||||||
|
|
||||||
|
@model_validator(mode='after')
|
||||||
|
def validate_playthrough_fields(self) -> Self:
|
||||||
|
if self.game_type == GameType.PLAYTHROUGH:
|
||||||
|
if self.playthrough_points is None:
|
||||||
|
raise ValueError('playthrough_points обязателен для типа "Прохождение"')
|
||||||
|
if self.playthrough_description is None:
|
||||||
|
raise ValueError('playthrough_description обязателен для типа "Прохождение"')
|
||||||
|
if self.playthrough_proof_type is None:
|
||||||
|
raise ValueError('playthrough_proof_type обязателен для типа "Прохождение"')
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class GameUpdate(BaseModel):
|
class GameUpdate(BaseModel):
|
||||||
title: str | None = Field(None, min_length=1, max_length=100)
|
title: str | None = Field(None, min_length=1, max_length=100)
|
||||||
download_url: str | None = None
|
download_url: str | None = None
|
||||||
genre: str | None = None
|
genre: str | None = None
|
||||||
|
|
||||||
|
# Тип игры
|
||||||
|
game_type: GameType | None = None
|
||||||
|
|
||||||
|
# Поля для типа "Прохождение"
|
||||||
|
playthrough_points: int | None = Field(None, ge=1, le=1000)
|
||||||
|
playthrough_description: str | None = None
|
||||||
|
playthrough_proof_type: ProofType | None = None
|
||||||
|
playthrough_proof_hint: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class GameShort(BaseModel):
|
class GameShort(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
cover_url: str | None = None
|
cover_url: str | None = None
|
||||||
|
download_url: str
|
||||||
|
game_type: str = "challenges"
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -38,5 +72,22 @@ class GameResponse(GameBase):
|
|||||||
challenges_count: int = 0
|
challenges_count: int = 0
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
# Тип игры
|
||||||
|
game_type: str = "challenges"
|
||||||
|
|
||||||
|
# Поля для типа "Прохождение"
|
||||||
|
playthrough_points: int | None = None
|
||||||
|
playthrough_description: str | None = None
|
||||||
|
playthrough_proof_type: str | None = None
|
||||||
|
playthrough_proof_hint: str | None = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class PlaythroughInfo(BaseModel):
|
||||||
|
"""Информация о прохождении для игр типа playthrough"""
|
||||||
|
description: str
|
||||||
|
points: int
|
||||||
|
proof_type: str
|
||||||
|
proof_hint: str | None = None
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class MarathonResponse(MarathonBase):
|
|||||||
is_public: bool
|
is_public: bool
|
||||||
game_proposal_mode: str
|
game_proposal_mode: str
|
||||||
auto_events_enabled: bool
|
auto_events_enabled: bool
|
||||||
|
cover_url: str | None
|
||||||
start_date: datetime | None
|
start_date: datetime | None
|
||||||
end_date: datetime | None
|
end_date: datetime | None
|
||||||
participants_count: int
|
participants_count: int
|
||||||
@@ -69,6 +70,7 @@ class MarathonListItem(BaseModel):
|
|||||||
title: str
|
title: str
|
||||||
status: str
|
status: str
|
||||||
is_public: bool
|
is_public: bool
|
||||||
|
cover_url: str | None
|
||||||
participants_count: int
|
participants_count: int
|
||||||
start_date: datetime | None
|
start_date: datetime | None
|
||||||
end_date: datetime | None
|
end_date: datetime | None
|
||||||
@@ -87,6 +89,7 @@ class MarathonPublicInfo(BaseModel):
|
|||||||
title: str
|
title: str
|
||||||
description: str | None
|
description: str | None
|
||||||
status: str
|
status: str
|
||||||
|
cover_url: str | None
|
||||||
participants_count: int
|
participants_count: int
|
||||||
creator_nickname: str
|
creator_nickname: str
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Dispute Scheduler for automatic dispute resolution after 24 hours.
|
Dispute Scheduler - marks disputes as pending admin review after 24 hours.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
@@ -8,16 +8,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.models import Dispute, DisputeStatus, Assignment, AssignmentStatus
|
from app.models import Dispute, DisputeStatus, Assignment, AssignmentStatus
|
||||||
from app.services.disputes import dispute_service
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
CHECK_INTERVAL_SECONDS = 300 # Check every 5 minutes
|
CHECK_INTERVAL_SECONDS = 300 # Check every 5 minutes
|
||||||
DISPUTE_WINDOW_HOURS = 24 # Disputes auto-resolve after 24 hours
|
DISPUTE_WINDOW_HOURS = 24 # Disputes need admin decision after 24 hours
|
||||||
|
|
||||||
|
|
||||||
class DisputeScheduler:
|
class DisputeScheduler:
|
||||||
"""Background scheduler for automatic dispute resolution."""
|
"""Background scheduler that marks expired disputes for admin review."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._running = False
|
self._running = False
|
||||||
@@ -55,7 +55,7 @@ class DisputeScheduler:
|
|||||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||||
|
|
||||||
async def _process_expired_disputes(self, db: AsyncSession) -> None:
|
async def _process_expired_disputes(self, db: AsyncSession) -> None:
|
||||||
"""Process and resolve expired disputes."""
|
"""Mark expired disputes as pending admin review."""
|
||||||
cutoff_time = datetime.utcnow() - timedelta(hours=DISPUTE_WINDOW_HOURS)
|
cutoff_time = datetime.utcnow() - timedelta(hours=DISPUTE_WINDOW_HOURS)
|
||||||
|
|
||||||
# Find all open disputes that have expired
|
# Find all open disputes that have expired
|
||||||
@@ -63,7 +63,6 @@ class DisputeScheduler:
|
|||||||
select(Dispute)
|
select(Dispute)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Dispute.votes),
|
selectinload(Dispute.votes),
|
||||||
selectinload(Dispute.assignment).selectinload(Assignment.participant),
|
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Dispute.status == DisputeStatus.OPEN.value,
|
Dispute.status == DisputeStatus.OPEN.value,
|
||||||
@@ -74,15 +73,25 @@ class DisputeScheduler:
|
|||||||
|
|
||||||
for dispute in expired_disputes:
|
for dispute in expired_disputes:
|
||||||
try:
|
try:
|
||||||
result_status, votes_valid, votes_invalid = await dispute_service.resolve_dispute(
|
# Count votes for logging
|
||||||
db, dispute.id
|
votes_valid = sum(1 for v in dispute.votes if v.vote is True)
|
||||||
)
|
votes_invalid = sum(1 for v in dispute.votes if v.vote is False)
|
||||||
|
|
||||||
|
# Mark as pending admin decision
|
||||||
|
dispute.status = DisputeStatus.PENDING_ADMIN.value
|
||||||
|
|
||||||
print(
|
print(
|
||||||
f"[DisputeScheduler] Auto-resolved dispute {dispute.id}: "
|
f"[DisputeScheduler] Dispute {dispute.id} marked as pending admin "
|
||||||
f"{result_status} (valid: {votes_valid}, invalid: {votes_invalid})"
|
f"(recommendation: {'invalid' if votes_invalid > votes_valid else 'valid'}, "
|
||||||
|
f"votes: {votes_valid} valid, {votes_invalid} invalid)"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[DisputeScheduler] Failed to resolve dispute {dispute.id}: {e}")
|
print(f"[DisputeScheduler] Failed to process dispute {dispute.id}: {e}")
|
||||||
|
|
||||||
|
if expired_disputes:
|
||||||
|
await db.commit()
|
||||||
|
# Notify admins about pending disputes
|
||||||
|
await telegram_notifier.notify_admin_disputes_pending(db, len(expired_disputes))
|
||||||
|
|
||||||
|
|
||||||
# Global scheduler instance
|
# Global scheduler instance
|
||||||
|
|||||||
@@ -23,12 +23,15 @@ class DisputeService:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (result_status, votes_valid, votes_invalid)
|
Tuple of (result_status, votes_valid, votes_invalid)
|
||||||
"""
|
"""
|
||||||
# Get dispute with votes and assignment
|
from app.models import BonusAssignment, BonusAssignmentStatus
|
||||||
|
|
||||||
|
# Get dispute with votes, assignment and bonus_assignment
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Dispute)
|
select(Dispute)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Dispute.votes),
|
selectinload(Dispute.votes),
|
||||||
selectinload(Dispute.assignment).selectinload(Assignment.participant),
|
selectinload(Dispute.assignment).selectinload(Assignment.participant),
|
||||||
|
selectinload(Dispute.bonus_assignment).selectinload(BonusAssignment.main_assignment).selectinload(Assignment.participant),
|
||||||
)
|
)
|
||||||
.where(Dispute.id == dispute_id)
|
.where(Dispute.id == dispute_id)
|
||||||
)
|
)
|
||||||
@@ -46,9 +49,12 @@ class DisputeService:
|
|||||||
|
|
||||||
# Determine result: tie goes to the accused (valid)
|
# Determine result: tie goes to the accused (valid)
|
||||||
if votes_invalid > votes_valid:
|
if votes_invalid > votes_valid:
|
||||||
# Proof is invalid - mark assignment as RETURNED
|
# Proof is invalid
|
||||||
result_status = DisputeStatus.RESOLVED_INVALID.value
|
result_status = DisputeStatus.RESOLVED_INVALID.value
|
||||||
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:
|
else:
|
||||||
# Proof is valid (or tie)
|
# Proof is valid (or tie)
|
||||||
result_status = DisputeStatus.RESOLVED_VALID.value
|
result_status = DisputeStatus.RESOLVED_VALID.value
|
||||||
@@ -60,7 +66,11 @@ class DisputeService:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Send Telegram notification about dispute resolution
|
# Send Telegram notification about dispute resolution
|
||||||
await self._notify_dispute_resolved(db, dispute, result_status == DisputeStatus.RESOLVED_INVALID.value)
|
is_invalid = result_status == DisputeStatus.RESOLVED_INVALID.value
|
||||||
|
if dispute.bonus_assignment_id:
|
||||||
|
await self._notify_bonus_dispute_resolved(db, dispute, is_invalid)
|
||||||
|
else:
|
||||||
|
await self._notify_dispute_resolved(db, dispute, is_invalid)
|
||||||
|
|
||||||
return result_status, votes_valid, votes_invalid
|
return result_status, votes_valid, votes_invalid
|
||||||
|
|
||||||
@@ -72,12 +82,13 @@ class DisputeService:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Send notification about dispute resolution to the assignment owner."""
|
"""Send notification about dispute resolution to the assignment owner."""
|
||||||
try:
|
try:
|
||||||
# Get assignment with challenge and marathon info
|
# Get assignment with challenge/game and marathon info
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Assignment)
|
select(Assignment)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Assignment.participant),
|
selectinload(Assignment.participant),
|
||||||
selectinload(Assignment.challenge).selectinload(Challenge.game)
|
selectinload(Assignment.challenge).selectinload(Challenge.game),
|
||||||
|
selectinload(Assignment.game), # For playthrough
|
||||||
)
|
)
|
||||||
.where(Assignment.id == dispute.assignment_id)
|
.where(Assignment.id == dispute.assignment_id)
|
||||||
)
|
)
|
||||||
@@ -86,12 +97,19 @@ class DisputeService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
participant = assignment.participant
|
participant = assignment.participant
|
||||||
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
|
# Get marathon
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Marathon).where(Marathon.id == game.marathon_id if game else 0)
|
select(Marathon).where(Marathon.id == marathon_id)
|
||||||
)
|
)
|
||||||
marathon = result.scalar_one_or_none()
|
marathon = result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -100,12 +118,86 @@ class DisputeService:
|
|||||||
db,
|
db,
|
||||||
user_id=participant.user_id,
|
user_id=participant.user_id,
|
||||||
marathon_title=marathon.title,
|
marathon_title=marathon.title,
|
||||||
challenge_title=challenge.title if challenge else "Unknown",
|
challenge_title=title,
|
||||||
is_valid=is_valid
|
is_valid=is_valid
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[DisputeService] Failed to send notification: {e}")
|
print(f"[DisputeService] Failed to send notification: {e}")
|
||||||
|
|
||||||
|
async def _notify_bonus_dispute_resolved(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
dispute: Dispute,
|
||||||
|
is_invalid: bool
|
||||||
|
) -> None:
|
||||||
|
"""Send notification about bonus dispute resolution to the assignment owner."""
|
||||||
|
try:
|
||||||
|
bonus_assignment = dispute.bonus_assignment
|
||||||
|
main_assignment = bonus_assignment.main_assignment
|
||||||
|
participant = main_assignment.participant
|
||||||
|
|
||||||
|
# Get marathon info
|
||||||
|
result = await db.execute(
|
||||||
|
select(Game).where(Game.id == main_assignment.game_id)
|
||||||
|
)
|
||||||
|
game = result.scalar_one_or_none()
|
||||||
|
if not game:
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Marathon).where(Marathon.id == game.marathon_id)
|
||||||
|
)
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# Get challenge title
|
||||||
|
result = await db.execute(
|
||||||
|
select(Challenge).where(Challenge.id == bonus_assignment.challenge_id)
|
||||||
|
)
|
||||||
|
challenge = result.scalar_one_or_none()
|
||||||
|
title = f"Бонус: {challenge.title}" if challenge else "Бонусный челлендж"
|
||||||
|
|
||||||
|
if marathon and participant:
|
||||||
|
await telegram_notifier.notify_dispute_resolved(
|
||||||
|
db,
|
||||||
|
user_id=participant.user_id,
|
||||||
|
marathon_title=marathon.title,
|
||||||
|
challenge_title=title,
|
||||||
|
is_valid=not is_invalid
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[DisputeService] Failed to send bonus dispute notification: {e}")
|
||||||
|
|
||||||
|
async def _handle_invalid_bonus_proof(self, db: AsyncSession, dispute: Dispute) -> None:
|
||||||
|
"""
|
||||||
|
Handle the case when bonus proof is determined to be invalid.
|
||||||
|
|
||||||
|
- Reset bonus assignment to PENDING
|
||||||
|
- If main playthrough was already completed, subtract bonus points from participant
|
||||||
|
"""
|
||||||
|
from app.models import BonusAssignment, BonusAssignmentStatus, AssignmentStatus
|
||||||
|
|
||||||
|
bonus_assignment = dispute.bonus_assignment
|
||||||
|
main_assignment = bonus_assignment.main_assignment
|
||||||
|
participant = main_assignment.participant
|
||||||
|
|
||||||
|
# If main playthrough was already completed, we need to subtract the bonus points
|
||||||
|
if main_assignment.status == AssignmentStatus.COMPLETED.value:
|
||||||
|
points_to_subtract = bonus_assignment.points_earned
|
||||||
|
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
||||||
|
# Also reduce the points_earned on the main assignment
|
||||||
|
main_assignment.points_earned = max(0, main_assignment.points_earned - points_to_subtract)
|
||||||
|
print(f"[DisputeService] Subtracted {points_to_subtract} points from participant {participant.id}")
|
||||||
|
|
||||||
|
# Reset bonus assignment
|
||||||
|
bonus_assignment.status = BonusAssignmentStatus.PENDING.value
|
||||||
|
bonus_assignment.proof_path = None
|
||||||
|
bonus_assignment.proof_url = None
|
||||||
|
bonus_assignment.proof_comment = None
|
||||||
|
bonus_assignment.points_earned = 0
|
||||||
|
bonus_assignment.completed_at = None
|
||||||
|
|
||||||
|
print(f"[DisputeService] Bonus assignment {bonus_assignment.id} reset to PENDING due to invalid dispute")
|
||||||
|
|
||||||
async def _handle_invalid_proof(self, db: AsyncSession, dispute: Dispute) -> None:
|
async def _handle_invalid_proof(self, db: AsyncSession, dispute: Dispute) -> None:
|
||||||
"""
|
"""
|
||||||
Handle the case when proof is determined to be invalid.
|
Handle the case when proof is determined to be invalid.
|
||||||
@@ -113,7 +205,10 @@ class DisputeService:
|
|||||||
- Mark assignment as RETURNED
|
- Mark assignment as RETURNED
|
||||||
- Subtract points from participant
|
- Subtract points from participant
|
||||||
- Reset streak if it was affected
|
- Reset streak if it was affected
|
||||||
|
- For playthrough: also reset bonus assignments
|
||||||
"""
|
"""
|
||||||
|
from app.models import BonusAssignment, BonusAssignmentStatus
|
||||||
|
|
||||||
assignment = dispute.assignment
|
assignment = dispute.assignment
|
||||||
participant = assignment.participant
|
participant = assignment.participant
|
||||||
|
|
||||||
@@ -121,22 +216,45 @@ class DisputeService:
|
|||||||
points_to_subtract = assignment.points_earned
|
points_to_subtract = assignment.points_earned
|
||||||
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
participant.total_points = max(0, participant.total_points - points_to_subtract)
|
||||||
|
|
||||||
|
# Reset streak - the completion was invalid so streak should be broken
|
||||||
|
participant.current_streak = 0
|
||||||
|
|
||||||
# Reset assignment
|
# Reset assignment
|
||||||
assignment.status = AssignmentStatus.RETURNED.value
|
assignment.status = AssignmentStatus.RETURNED.value
|
||||||
assignment.points_earned = 0
|
assignment.points_earned = 0
|
||||||
# Keep proof data so it can be reviewed
|
# Keep proof data so it can be reviewed
|
||||||
|
|
||||||
|
# For playthrough: reset all bonus assignments
|
||||||
|
if assignment.is_playthrough:
|
||||||
|
result = await db.execute(
|
||||||
|
select(BonusAssignment).where(BonusAssignment.main_assignment_id == assignment.id)
|
||||||
|
)
|
||||||
|
bonus_assignments = result.scalars().all()
|
||||||
|
for ba in bonus_assignments:
|
||||||
|
ba.status = BonusAssignmentStatus.PENDING.value
|
||||||
|
ba.proof_path = None
|
||||||
|
ba.proof_url = None
|
||||||
|
ba.proof_comment = None
|
||||||
|
ba.points_earned = 0
|
||||||
|
ba.completed_at = None
|
||||||
|
print(f"[DisputeService] Reset {len(bonus_assignments)} bonus assignments for playthrough {assignment.id}")
|
||||||
|
|
||||||
print(f"[DisputeService] Assignment {assignment.id} marked as RETURNED, "
|
print(f"[DisputeService] Assignment {assignment.id} marked as RETURNED, "
|
||||||
f"subtracted {points_to_subtract} points from participant {participant.id}")
|
f"subtracted {points_to_subtract} points from participant {participant.id}")
|
||||||
|
|
||||||
async def get_pending_disputes(self, db: AsyncSession, older_than_hours: int = 24) -> list[Dispute]:
|
async def get_pending_disputes(self, db: AsyncSession, older_than_hours: int = 24) -> list[Dispute]:
|
||||||
"""Get all open disputes older than specified hours"""
|
"""Get all open disputes (both regular and bonus) older than specified hours"""
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from app.models import BonusAssignment
|
||||||
|
|
||||||
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
|
cutoff_time = datetime.utcnow() - timedelta(hours=older_than_hours)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Dispute)
|
select(Dispute)
|
||||||
|
.options(
|
||||||
|
selectinload(Dispute.assignment),
|
||||||
|
selectinload(Dispute.bonus_assignment),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
Dispute.status == DisputeStatus.OPEN.value,
|
Dispute.status == DisputeStatus.OPEN.value,
|
||||||
Dispute.created_at < cutoff_time,
|
Dispute.created_at < cutoff_time,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from app.core.config import settings
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
StorageFolder = Literal["avatars", "covers", "proofs"]
|
StorageFolder = Literal["avatars", "covers", "proofs", "bonus_proofs"]
|
||||||
|
|
||||||
|
|
||||||
class StorageService:
|
class StorageService:
|
||||||
|
|||||||
@@ -312,6 +312,43 @@ class TelegramNotifier:
|
|||||||
)
|
)
|
||||||
return await self.notify_user(db, user_id, message)
|
return await self.notify_user(db, user_id, message)
|
||||||
|
|
||||||
|
async def notify_admin_disputes_pending(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
count: int
|
||||||
|
) -> bool:
|
||||||
|
"""Notify admin about disputes waiting for decision."""
|
||||||
|
if not settings.TELEGRAM_ADMIN_ID:
|
||||||
|
logger.warning("[Notify] No TELEGRAM_ADMIN_ID configured")
|
||||||
|
return False
|
||||||
|
|
||||||
|
admin_url = f"{settings.FRONTEND_URL}/admin/disputes"
|
||||||
|
use_inline_button = admin_url.startswith("https://")
|
||||||
|
|
||||||
|
if use_inline_button:
|
||||||
|
message = (
|
||||||
|
f"⚠️ <b>{count} оспаривани{'е' if count == 1 else 'й'} ожида{'ет' if count == 1 else 'ют'} решения</b>\n\n"
|
||||||
|
f"Голосование завершено, требуется ваше решение."
|
||||||
|
)
|
||||||
|
reply_markup = {
|
||||||
|
"inline_keyboard": [[
|
||||||
|
{"text": "Открыть оспаривания", "url": admin_url}
|
||||||
|
]]
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
f"⚠️ <b>{count} оспаривани{'е' if count == 1 else 'й'} ожида{'ет' if count == 1 else 'ют'} решения</b>\n\n"
|
||||||
|
f"Голосование завершено, требуется ваше решение.\n\n"
|
||||||
|
f"🔗 {admin_url}"
|
||||||
|
)
|
||||||
|
reply_markup = None
|
||||||
|
|
||||||
|
return await self.send_message(
|
||||||
|
int(settings.TELEGRAM_ADMIN_ID),
|
||||||
|
message,
|
||||||
|
reply_markup=reply_markup
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
# Global instance
|
||||||
telegram_notifier = TelegramNotifier()
|
telegram_notifier = TelegramNotifier()
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ def create_backup() -> tuple[str, bytes]:
|
|||||||
config.DB_NAME,
|
config.DB_NAME,
|
||||||
"--no-owner",
|
"--no-owner",
|
||||||
"--no-acl",
|
"--no-acl",
|
||||||
|
"--clean", # Add DROP commands before CREATE
|
||||||
|
"--if-exists", # Use IF EXISTS with DROP commands
|
||||||
"-F",
|
"-F",
|
||||||
"p", # plain SQL format
|
"p", # plain SQL format
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
Restore PostgreSQL database from S3 backup.
|
Restore PostgreSQL database from S3 backup.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python restore.py - List available backups
|
python restore.py - List available backups
|
||||||
python restore.py <filename> - Restore from specific backup
|
python restore.py <filename> - Restore from backup (cleans DB first)
|
||||||
|
python restore.py <filename> --no-clean - Restore without cleaning DB first
|
||||||
"""
|
"""
|
||||||
import gzip
|
import gzip
|
||||||
import os
|
import os
|
||||||
@@ -62,7 +63,48 @@ def list_backups(s3_client) -> list[tuple[str, float, str]]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def restore_backup(s3_client, filename: str) -> None:
|
def clean_database() -> None:
|
||||||
|
"""Drop and recreate public schema to clean the database."""
|
||||||
|
print("Cleaning database (dropping and recreating public schema)...")
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PGPASSWORD"] = config.DB_PASSWORD
|
||||||
|
|
||||||
|
# Drop and recreate public schema
|
||||||
|
clean_sql = b"""
|
||||||
|
DROP SCHEMA public CASCADE;
|
||||||
|
CREATE SCHEMA public;
|
||||||
|
GRANT ALL ON SCHEMA public TO public;
|
||||||
|
"""
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"psql",
|
||||||
|
"-h",
|
||||||
|
config.DB_HOST,
|
||||||
|
"-p",
|
||||||
|
config.DB_PORT,
|
||||||
|
"-U",
|
||||||
|
config.DB_USER,
|
||||||
|
"-d",
|
||||||
|
config.DB_NAME,
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
env=env,
|
||||||
|
input=clean_sql,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
stderr = result.stderr.decode()
|
||||||
|
if "ERROR" in stderr:
|
||||||
|
raise Exception(f"Database cleanup failed: {stderr}")
|
||||||
|
|
||||||
|
print("Database cleaned successfully!")
|
||||||
|
|
||||||
|
|
||||||
|
def restore_backup(s3_client, filename: str, clean_first: bool = True) -> None:
|
||||||
"""Download and restore backup."""
|
"""Download and restore backup."""
|
||||||
key = f"{config.S3_BACKUP_PREFIX}{filename}"
|
key = f"{config.S3_BACKUP_PREFIX}{filename}"
|
||||||
|
|
||||||
@@ -79,6 +121,10 @@ def restore_backup(s3_client, filename: str) -> None:
|
|||||||
print("Decompressing...")
|
print("Decompressing...")
|
||||||
sql_data = gzip.decompress(compressed_data)
|
sql_data = gzip.decompress(compressed_data)
|
||||||
|
|
||||||
|
# Clean database before restore if requested
|
||||||
|
if clean_first:
|
||||||
|
clean_database()
|
||||||
|
|
||||||
print(f"Restoring to database {config.DB_NAME}...")
|
print(f"Restoring to database {config.DB_NAME}...")
|
||||||
|
|
||||||
# Build psql command
|
# Build psql command
|
||||||
@@ -124,20 +170,32 @@ def main() -> int:
|
|||||||
|
|
||||||
s3_client = create_s3_client()
|
s3_client = create_s3_client()
|
||||||
|
|
||||||
if len(sys.argv) < 2:
|
# Parse arguments
|
||||||
|
args = sys.argv[1:]
|
||||||
|
clean_first = True
|
||||||
|
|
||||||
|
if "--no-clean" in args:
|
||||||
|
clean_first = False
|
||||||
|
args.remove("--no-clean")
|
||||||
|
|
||||||
|
if len(args) < 1:
|
||||||
# List available backups
|
# List available backups
|
||||||
backups = list_backups(s3_client)
|
backups = list_backups(s3_client)
|
||||||
if backups:
|
if backups:
|
||||||
print(f"\nTo restore, run: python restore.py <filename>")
|
print(f"\nTo restore, run: python restore.py <filename>")
|
||||||
|
print("Add --no-clean to skip database cleanup before restore")
|
||||||
else:
|
else:
|
||||||
print("No backups found.")
|
print("No backups found.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
filename = sys.argv[1]
|
filename = args[0]
|
||||||
|
|
||||||
# Confirm restore
|
# Confirm restore
|
||||||
print(f"WARNING: This will restore database from {filename}")
|
print(f"WARNING: This will restore database from {filename}")
|
||||||
print("This may overwrite existing data!")
|
if clean_first:
|
||||||
|
print("Database will be CLEANED (all existing data will be DELETED)!")
|
||||||
|
else:
|
||||||
|
print("Database will NOT be cleaned (may cause conflicts with existing data)")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
confirm = input("Type 'yes' to continue: ")
|
confirm = input("Type 'yes' to continue: ")
|
||||||
@@ -147,7 +205,7 @@ def main() -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
restore_backup(s3_client, filename)
|
restore_backup(s3_client, filename, clean_first=clean_first)
|
||||||
return 0
|
return 0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Restore failed: {e}")
|
print(f"Restore failed: {e}")
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5433:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U marathon"]
|
test: ["CMD-SHELL", "pg_isready -U marathon"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -30,6 +30,7 @@ services:
|
|||||||
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-BCMarathonbot}
|
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-BCMarathonbot}
|
||||||
BOT_API_SECRET: ${BOT_API_SECRET:-}
|
BOT_API_SECRET: ${BOT_API_SECRET:-}
|
||||||
DEBUG: ${DEBUG:-false}
|
DEBUG: ${DEBUG:-false}
|
||||||
|
RATE_LIMIT_ENABLED: ${RATE_LIMIT_ENABLED:-true}
|
||||||
# S3 Storage
|
# S3 Storage
|
||||||
S3_ENABLED: ${S3_ENABLED:-false}
|
S3_ENABLED: ${S3_ENABLED:-false}
|
||||||
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-}
|
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-}
|
||||||
@@ -42,7 +43,7 @@ services:
|
|||||||
- ./backend/uploads:/app/uploads
|
- ./backend/uploads:/app/uploads
|
||||||
- ./backend/app:/app/app
|
- ./backend/app:/app/app
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8002:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -56,7 +57,7 @@ services:
|
|||||||
VITE_API_URL: ${VITE_API_URL:-/api/v1}
|
VITE_API_URL: ${VITE_API_URL:-/api/v1}
|
||||||
container_name: marathon-frontend
|
container_name: marathon-frontend
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "3002:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
381
docs/disputes.md
Normal file
381
docs/disputes.md
Normal 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
242
docs/game-types.md
Normal 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
906
docs/tz-game-types.md
Normal 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 │ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ Доп. задания: │ │ │
|
||||||
|
│ Все челленджи │ │ │
|
||||||
|
│ (опционально) │ │ │
|
||||||
|
└─────────────────┘ └─────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ Пруф: │ │ Пруф: │
|
||||||
|
│ На прохождение │ │ По типу │
|
||||||
|
│ игры │ │ челленджа │
|
||||||
|
└─────────────────┘ └─────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ Очки: │ │ Очки: │
|
||||||
|
│ + За прохождение│ │ + За челлендж │
|
||||||
|
│ + Бонус за доп. │ │ │
|
||||||
|
│ челленджи │ │ │
|
||||||
|
└─────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
BIN
frontend/public/telegram_banner.png
Normal file
BIN
frontend/public/telegram_banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
frontend/public/telegram_bot_banner.png
Normal file
BIN
frontend/public/telegram_bot_banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { ToastContainer, ConfirmModal } from '@/components/ui'
|
import { ToastContainer, ConfirmModal } from '@/components/ui'
|
||||||
@@ -20,6 +21,7 @@ import { InvitePage } from '@/pages/InvitePage'
|
|||||||
import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage'
|
import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage'
|
||||||
import { ProfilePage } from '@/pages/ProfilePage'
|
import { ProfilePage } from '@/pages/ProfilePage'
|
||||||
import { UserProfilePage } from '@/pages/UserProfilePage'
|
import { UserProfilePage } from '@/pages/UserProfilePage'
|
||||||
|
import { StaticContentPage } from '@/pages/StaticContentPage'
|
||||||
import { NotFoundPage } from '@/pages/NotFoundPage'
|
import { NotFoundPage } from '@/pages/NotFoundPage'
|
||||||
import { TeapotPage } from '@/pages/TeapotPage'
|
import { TeapotPage } from '@/pages/TeapotPage'
|
||||||
import { ServerErrorPage } from '@/pages/ServerErrorPage'
|
import { ServerErrorPage } from '@/pages/ServerErrorPage'
|
||||||
@@ -30,6 +32,7 @@ import {
|
|||||||
AdminDashboardPage,
|
AdminDashboardPage,
|
||||||
AdminUsersPage,
|
AdminUsersPage,
|
||||||
AdminMarathonsPage,
|
AdminMarathonsPage,
|
||||||
|
AdminDisputesPage,
|
||||||
AdminLogsPage,
|
AdminLogsPage,
|
||||||
AdminBroadcastPage,
|
AdminBroadcastPage,
|
||||||
AdminContentPage,
|
AdminContentPage,
|
||||||
@@ -59,10 +62,15 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const banInfo = useAuthStore((state) => state.banInfo)
|
const banInfo = useAuthStore((state) => state.banInfo)
|
||||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
const syncUser = useAuthStore((state) => state.syncUser)
|
||||||
|
|
||||||
// Show banned screen if user is authenticated and banned
|
// Sync user data with server on app load
|
||||||
if (isAuthenticated && banInfo) {
|
useEffect(() => {
|
||||||
|
syncUser()
|
||||||
|
}, [syncUser])
|
||||||
|
|
||||||
|
// Show banned screen if user is banned (either authenticated or during login attempt)
|
||||||
|
if (banInfo) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
@@ -82,6 +90,11 @@ function App() {
|
|||||||
{/* Public invite page */}
|
{/* Public invite page */}
|
||||||
<Route path="invite/:code" element={<InvitePage />} />
|
<Route path="invite/:code" element={<InvitePage />} />
|
||||||
|
|
||||||
|
{/* Public static content pages */}
|
||||||
|
<Route path="terms" element={<StaticContentPage />} />
|
||||||
|
<Route path="privacy" element={<StaticContentPage />} />
|
||||||
|
<Route path="page/:key" element={<StaticContentPage />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="login"
|
path="login"
|
||||||
element={
|
element={
|
||||||
@@ -196,6 +209,7 @@ function App() {
|
|||||||
<Route index element={<AdminDashboardPage />} />
|
<Route index element={<AdminDashboardPage />} />
|
||||||
<Route path="users" element={<AdminUsersPage />} />
|
<Route path="users" element={<AdminUsersPage />} />
|
||||||
<Route path="marathons" element={<AdminMarathonsPage />} />
|
<Route path="marathons" element={<AdminMarathonsPage />} />
|
||||||
|
<Route path="disputes" element={<AdminDisputesPage />} />
|
||||||
<Route path="logs" element={<AdminLogsPage />} />
|
<Route path="logs" element={<AdminLogsPage />} />
|
||||||
<Route path="broadcast" element={<AdminBroadcastPage />} />
|
<Route path="broadcast" element={<AdminBroadcastPage />} />
|
||||||
<Route path="content" element={<AdminContentPage />} />
|
<Route path="content" element={<AdminContentPage />} />
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import type {
|
|||||||
AdminLogsResponse,
|
AdminLogsResponse,
|
||||||
BroadcastResponse,
|
BroadcastResponse,
|
||||||
StaticContent,
|
StaticContent,
|
||||||
DashboardStats
|
DashboardStats,
|
||||||
|
AdminDispute
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
|
|
||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
@@ -52,6 +53,13 @@ export const adminApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
resetUserPassword: async (id: number, newPassword: string): Promise<AdminUser> => {
|
||||||
|
const response = await client.post<AdminUser>(`/admin/users/${id}/reset-password`, {
|
||||||
|
new_password: newPassword,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
// Marathons
|
// Marathons
|
||||||
listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => {
|
listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => {
|
||||||
const params: Record<string, unknown> = { skip, limit }
|
const params: Record<string, unknown> = { skip, limit }
|
||||||
@@ -114,6 +122,23 @@ export const adminApi = {
|
|||||||
const response = await client.post<StaticContent>('/admin/content', { key, title, content })
|
const response = await client.post<StaticContent>('/admin/content', { key, title, content })
|
||||||
return response.data
|
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)
|
// Public content API (no auth required)
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import client from './client'
|
import client from './client'
|
||||||
import type { AssignmentDetail, Dispute, DisputeComment, ReturnedAssignment } from '@/types'
|
import type { AssignmentDetail, Dispute, DisputeComment, ReturnedAssignment, BonusAssignment } from '@/types'
|
||||||
|
|
||||||
|
export interface BonusCompleteResult {
|
||||||
|
bonus_assignment_id: number
|
||||||
|
points_earned: number
|
||||||
|
total_bonus_points: number
|
||||||
|
}
|
||||||
|
|
||||||
export const assignmentsApi = {
|
export const assignmentsApi = {
|
||||||
// Get detailed assignment info with proofs and dispute
|
// Get detailed assignment info with proofs and dispute
|
||||||
@@ -14,6 +20,12 @@ export const assignmentsApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Create a dispute against a bonus assignment
|
||||||
|
createBonusDispute: async (bonusId: number, reason: string): Promise<Dispute> => {
|
||||||
|
const response = await client.post<Dispute>(`/bonus-assignments/${bonusId}/dispute`, { reason })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
// Add a comment to a dispute
|
// Add a comment to a dispute
|
||||||
addComment: async (disputeId: number, text: string): Promise<DisputeComment> => {
|
addComment: async (disputeId: number, text: string): Promise<DisputeComment> => {
|
||||||
const response = await client.post<DisputeComment>(`/disputes/${disputeId}/comments`, { text })
|
const response = await client.post<DisputeComment>(`/disputes/${disputeId}/comments`, { text })
|
||||||
@@ -44,4 +56,95 @@ export const assignmentsApi = {
|
|||||||
type: isVideo ? 'video' : 'image',
|
type: isVideo ? 'video' : 'image',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Get bonus assignments for a playthrough assignment
|
||||||
|
getBonusAssignments: async (assignmentId: number): Promise<BonusAssignment[]> => {
|
||||||
|
const response = await client.get<BonusAssignment[]>(`/assignments/${assignmentId}/bonus`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Complete a bonus challenge
|
||||||
|
completeBonusAssignment: async (
|
||||||
|
assignmentId: number,
|
||||||
|
bonusId: number,
|
||||||
|
data: { proof_file?: File; proof_files?: File[]; proof_url?: string; comment?: string }
|
||||||
|
): Promise<BonusCompleteResult> => {
|
||||||
|
const formData = new FormData()
|
||||||
|
|
||||||
|
// Support both single file (legacy) and multiple files
|
||||||
|
if (data.proof_file) {
|
||||||
|
formData.append('proof_file', data.proof_file)
|
||||||
|
}
|
||||||
|
if (data.proof_files && data.proof_files.length > 0) {
|
||||||
|
data.proof_files.forEach(file => {
|
||||||
|
formData.append('proof_files', file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.proof_url) {
|
||||||
|
formData.append('proof_url', data.proof_url)
|
||||||
|
}
|
||||||
|
if (data.comment) {
|
||||||
|
formData.append('comment', data.comment)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await client.post<BonusCompleteResult>(
|
||||||
|
`/assignments/${assignmentId}/bonus/${bonusId}/complete`,
|
||||||
|
formData,
|
||||||
|
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get bonus proof media as blob URL (supports both images and videos)
|
||||||
|
getBonusProofMediaUrl: async (
|
||||||
|
assignmentId: number,
|
||||||
|
bonusId: number
|
||||||
|
): Promise<{ url: string; type: 'image' | 'video' }> => {
|
||||||
|
const response = await client.get(
|
||||||
|
`/assignments/${assignmentId}/bonus/${bonusId}/proof-media`,
|
||||||
|
{ responseType: 'blob' }
|
||||||
|
)
|
||||||
|
const contentType = response.headers['content-type'] || ''
|
||||||
|
const isVideo = contentType.startsWith('video/')
|
||||||
|
return {
|
||||||
|
url: URL.createObjectURL(response.data),
|
||||||
|
type: isVideo ? 'video' : 'image',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get individual proof file media as blob URL (for multiple proofs support)
|
||||||
|
getProofFileMediaUrl: async (
|
||||||
|
assignmentId: number,
|
||||||
|
proofFileId: number
|
||||||
|
): Promise<{ url: string; type: 'image' | 'video' }> => {
|
||||||
|
const response = await client.get(
|
||||||
|
`/assignments/${assignmentId}/proof-files/${proofFileId}/media`,
|
||||||
|
{ responseType: 'blob' }
|
||||||
|
)
|
||||||
|
const contentType = response.headers['content-type'] || ''
|
||||||
|
const isVideo = contentType.startsWith('video/')
|
||||||
|
return {
|
||||||
|
url: URL.createObjectURL(response.data),
|
||||||
|
type: isVideo ? 'video' : 'image',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get individual bonus proof file media as blob URL (for multiple proofs support)
|
||||||
|
getBonusProofFileMediaUrl: async (
|
||||||
|
assignmentId: number,
|
||||||
|
bonusId: number,
|
||||||
|
proofFileId: number
|
||||||
|
): Promise<{ url: string; type: 'image' | 'video' }> => {
|
||||||
|
const response = await client.get(
|
||||||
|
`/assignments/${assignmentId}/bonus/${bonusId}/proof-files/${proofFileId}/media`,
|
||||||
|
{ responseType: 'blob' }
|
||||||
|
)
|
||||||
|
const contentType = response.headers['content-type'] || ''
|
||||||
|
const isVideo = contentType.startsWith('video/')
|
||||||
|
return {
|
||||||
|
url: URL.createObjectURL(response.data),
|
||||||
|
type: isVideo ? 'video' : 'image',
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,11 +33,16 @@ function isBanInfo(detail: unknown): detail is BanInfo {
|
|||||||
client.interceptors.response.use(
|
client.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error: AxiosError<{ detail: string | BanInfo }>) => {
|
(error: AxiosError<{ detail: string | BanInfo }>) => {
|
||||||
// Unauthorized - redirect to login
|
// Unauthorized - redirect to login (but not for auth endpoints)
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
localStorage.removeItem('token')
|
const url = error.config?.url || ''
|
||||||
localStorage.removeItem('user')
|
const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/register') || url.includes('/auth/2fa')
|
||||||
window.location.href = '/login'
|
|
||||||
|
if (!isAuthEndpoint) {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forbidden - check if user is banned
|
// Forbidden - check if user is banned
|
||||||
|
|||||||
@@ -1,11 +1,28 @@
|
|||||||
import client from './client'
|
import client from './client'
|
||||||
import type { Game, GameStatus, Challenge, ChallengePreview, ChallengesPreviewResponse } from '@/types'
|
import type { Game, GameStatus, GameType, ProofType, Challenge, ChallengePreview, ChallengesPreviewResponse, AvailableGamesCount } from '@/types'
|
||||||
|
|
||||||
export interface CreateGameData {
|
export interface CreateGameData {
|
||||||
title: string
|
title: string
|
||||||
download_url: string
|
download_url: string
|
||||||
genre?: string
|
genre?: string
|
||||||
cover_url?: string
|
cover_url?: string
|
||||||
|
// Game type fields
|
||||||
|
game_type?: GameType
|
||||||
|
playthrough_points?: number
|
||||||
|
playthrough_description?: string
|
||||||
|
playthrough_proof_type?: ProofType
|
||||||
|
playthrough_proof_hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateGameData {
|
||||||
|
title?: string
|
||||||
|
download_url?: string
|
||||||
|
genre?: string
|
||||||
|
game_type?: GameType
|
||||||
|
playthrough_points?: number
|
||||||
|
playthrough_description?: string
|
||||||
|
playthrough_proof_type?: ProofType
|
||||||
|
playthrough_proof_hint?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateChallengeData {
|
export interface CreateChallengeData {
|
||||||
@@ -45,6 +62,21 @@ export const gamesApi = {
|
|||||||
await client.delete(`/games/${id}`)
|
await client.delete(`/games/${id}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
update: async (id: number, data: UpdateGameData): Promise<Game> => {
|
||||||
|
const response = await client.patch<Game>(`/games/${id}`, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getAvailableGamesCount: async (marathonId: number): Promise<AvailableGamesCount> => {
|
||||||
|
const response = await client.get<AvailableGamesCount>(`/marathons/${marathonId}/available-games-count`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getAvailableGames: async (marathonId: number): Promise<Game[]> => {
|
||||||
|
const response = await client.get<Game[]>(`/marathons/${marathonId}/available-games`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
approve: async (id: number): Promise<Game> => {
|
approve: async (id: number): Promise<Game> => {
|
||||||
const response = await client.post<Game>(`/games/${id}/approve`)
|
const response = await client.post<Game>(`/games/${id}/approve`)
|
||||||
return response.data
|
return response.data
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import client from './client'
|
import client from './client'
|
||||||
import type { Marathon, MarathonListItem, MarathonPublicInfo, LeaderboardEntry, ParticipantWithUser, ParticipantRole, GameProposalMode } from '@/types'
|
import type { Marathon, MarathonListItem, MarathonPublicInfo, MarathonUpdate, LeaderboardEntry, ParticipantWithUser, ParticipantRole, GameProposalMode, MarathonDispute } from '@/types'
|
||||||
|
|
||||||
export interface CreateMarathonData {
|
export interface CreateMarathonData {
|
||||||
title: string
|
title: string
|
||||||
@@ -10,6 +10,8 @@ export interface CreateMarathonData {
|
|||||||
game_proposal_mode?: GameProposalMode
|
game_proposal_mode?: GameProposalMode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type { MarathonUpdate }
|
||||||
|
|
||||||
export const marathonsApi = {
|
export const marathonsApi = {
|
||||||
list: async (): Promise<MarathonListItem[]> => {
|
list: async (): Promise<MarathonListItem[]> => {
|
||||||
const response = await client.get<MarathonListItem[]>('/marathons')
|
const response = await client.get<MarathonListItem[]>('/marathons')
|
||||||
@@ -32,7 +34,7 @@ export const marathonsApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
update: async (id: number, data: Partial<CreateMarathonData>): Promise<Marathon> => {
|
update: async (id: number, data: MarathonUpdate): Promise<Marathon> => {
|
||||||
const response = await client.patch<Marathon>(`/marathons/${id}`, data)
|
const response = await client.patch<Marathon>(`/marathons/${id}`, data)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
@@ -78,4 +80,36 @@ export const marathonsApi = {
|
|||||||
const response = await client.get<LeaderboardEntry[]>(`/marathons/${id}/leaderboard`)
|
const response = await client.get<LeaderboardEntry[]>(`/marathons/${id}/leaderboard`)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
uploadCover: async (id: number, file: File): Promise<Marathon> => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
const response = await client.post<Marathon>(`/marathons/${id}/cover`, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteCover: async (id: number): Promise<Marathon> => {
|
||||||
|
const response = await client.delete<Marathon>(`/marathons/${id}/cover`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Disputes management for organizers
|
||||||
|
listDisputes: async (id: number, status: 'open' | 'all' = 'open'): Promise<MarathonDispute[]> => {
|
||||||
|
const response = await client.get<MarathonDispute[]>(`/marathons/${id}/disputes`, {
|
||||||
|
params: { status_filter: status }
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
resolveDispute: async (marathonId: number, disputeId: number, isValid: boolean): Promise<{ message: string }> => {
|
||||||
|
const response = await client.post<{ message: string }>(
|
||||||
|
`/marathons/${marathonId}/disputes/${disputeId}/resolve`,
|
||||||
|
{ is_valid: isValid }
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,19 @@ export const wheelApi = {
|
|||||||
|
|
||||||
complete: async (
|
complete: async (
|
||||||
assignmentId: number,
|
assignmentId: number,
|
||||||
data: { proof_url?: string; comment?: string; proof_file?: File }
|
data: { proof_url?: string; comment?: string; proof_file?: File; proof_files?: File[] }
|
||||||
): Promise<CompleteResult> => {
|
): Promise<CompleteResult> => {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
if (data.proof_url) formData.append('proof_url', data.proof_url)
|
if (data.proof_url) formData.append('proof_url', data.proof_url)
|
||||||
if (data.comment) formData.append('comment', data.comment)
|
if (data.comment) formData.append('comment', data.comment)
|
||||||
|
|
||||||
|
// Support both single file (legacy) and multiple files
|
||||||
if (data.proof_file) formData.append('proof_file', data.proof_file)
|
if (data.proof_file) formData.append('proof_file', data.proof_file)
|
||||||
|
if (data.proof_files && data.proof_files.length > 0) {
|
||||||
|
data.proof_files.forEach(file => {
|
||||||
|
formData.append('proof_files', file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const response = await client.post<CompleteResult>(`/assignments/${assignmentId}/complete`, formData, {
|
const response = await client.post<CompleteResult>(`/assignments/${assignmentId}/complete`, formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
|||||||
@@ -191,14 +191,15 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
|
|||||||
const isEvent = isEventActivity(activity.type)
|
const isEvent = isEventActivity(activity.type)
|
||||||
const { title, details, extra } = formatActivityMessage(activity)
|
const { title, details, extra } = formatActivityMessage(activity)
|
||||||
|
|
||||||
// Get assignment_id and dispute status for complete activities
|
// Get assignment_id, dispute status, and is_redo for complete activities
|
||||||
const activityData = activity.data as { assignment_id?: number; dispute_status?: string } | null
|
const activityData = activity.data as { assignment_id?: number; dispute_status?: string; is_redo?: boolean } | null
|
||||||
const assignmentId = activity.type === 'complete' && activityData?.assignment_id
|
const assignmentId = activity.type === 'complete' && activityData?.assignment_id
|
||||||
? activityData.assignment_id
|
? activityData.assignment_id
|
||||||
: null
|
: null
|
||||||
const disputeStatus = activity.type === 'complete' && activityData?.dispute_status
|
const disputeStatus = activity.type === 'complete' && activityData?.dispute_status
|
||||||
? activityData.dispute_status
|
? activityData.dispute_status
|
||||||
: null
|
: null
|
||||||
|
const isRedo = activity.type === 'complete' && activityData?.is_redo === true
|
||||||
|
|
||||||
// Determine accent color based on activity type
|
// Determine accent color based on activity type
|
||||||
const getAccentConfig = () => {
|
const getAccentConfig = () => {
|
||||||
@@ -323,6 +324,12 @@ function ActivityItem({ activity, isNew }: ActivityItemProps) {
|
|||||||
<ExternalLink className="w-3 h-3" />
|
<ExternalLink className="w-3 h-3" />
|
||||||
Детали
|
Детали
|
||||||
</button>
|
</button>
|
||||||
|
{isRedo && (
|
||||||
|
<span className="text-xs text-purple-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-purple-500/10">
|
||||||
|
<Zap className="w-3 h-3" />
|
||||||
|
Перепрохождение
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{disputeStatus === 'open' && (
|
{disputeStatus === 'open' && (
|
||||||
<span className="text-xs text-orange-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-orange-500/10">
|
<span className="text-xs text-orange-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-orange-500/10">
|
||||||
<AlertTriangle className="w-3 h-3" />
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
|||||||
78
frontend/src/components/AnnouncementBanner.tsx
Normal file
78
frontend/src/components/AnnouncementBanner.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ interface BanInfo {
|
|||||||
|
|
||||||
interface BannedScreenProps {
|
interface BannedScreenProps {
|
||||||
banInfo: BanInfo
|
banInfo: BanInfo
|
||||||
|
onLogout?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string | null) {
|
function formatDate(dateStr: string | null) {
|
||||||
@@ -24,8 +25,9 @@ function formatDate(dateStr: string | null) {
|
|||||||
}) + ' (МСК)'
|
}) + ' (МСК)'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BannedScreen({ banInfo }: BannedScreenProps) {
|
export function BannedScreen({ banInfo, onLogout }: BannedScreenProps) {
|
||||||
const logout = useAuthStore((state) => state.logout)
|
const storeLogout = useAuthStore((state) => state.logout)
|
||||||
|
const handleLogout = onLogout || storeLogout
|
||||||
|
|
||||||
const bannedAtFormatted = formatDate(banInfo.banned_at)
|
const bannedAtFormatted = formatDate(banInfo.banned_at)
|
||||||
const bannedUntilFormatted = formatDate(banInfo.banned_until)
|
const bannedUntilFormatted = formatDate(banInfo.banned_until)
|
||||||
@@ -112,7 +114,7 @@ export function BannedScreen({ banInfo }: BannedScreenProps) {
|
|||||||
<NeonButton
|
<NeonButton
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="lg"
|
size="lg"
|
||||||
onClick={logout}
|
onClick={handleLogout}
|
||||||
icon={<LogOut className="w-5 h-5" />}
|
icon={<LogOut className="w-5 h-5" />}
|
||||||
>
|
>
|
||||||
Выйти из аккаунта
|
Выйти из аккаунта
|
||||||
|
|||||||
501
frontend/src/components/MarathonSettingsModal.tsx
Normal file
501
frontend/src/components/MarathonSettingsModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useMemo } from 'react'
|
import { useState, useCallback, useMemo, useEffect } from 'react'
|
||||||
import type { Game } from '@/types'
|
import type { Game } from '@/types'
|
||||||
import { Gamepad2, Loader2 } from 'lucide-react'
|
import { Gamepad2, Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
@@ -9,27 +9,43 @@ interface SpinWheelProps {
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPIN_DURATION = 5000 // ms
|
const SPIN_DURATION = 6000 // ms - увеличено для более плавного замедления
|
||||||
const EXTRA_ROTATIONS = 5
|
const EXTRA_ROTATIONS = 7 // больше оборотов для эффекта инерции
|
||||||
|
|
||||||
// Цветовая палитра секторов
|
// Пороги для адаптивного отображения
|
||||||
|
const TEXT_THRESHOLD = 16 // До 16 игр - показываем текст
|
||||||
|
const LINES_THRESHOLD = 40 // До 40 игр - показываем разделители
|
||||||
|
|
||||||
|
// Цветовая палитра секторов (расширенная для большего количества)
|
||||||
const SECTOR_COLORS = [
|
const SECTOR_COLORS = [
|
||||||
{ bg: '#0d9488', border: '#14b8a6' }, // teal
|
{ bg: '#0d9488', border: '#14b8a6' }, // teal
|
||||||
{ bg: '#7c3aed', border: '#8b5cf6' }, // violet
|
{ bg: '#7c3aed', border: '#8b5cf6' }, // violet
|
||||||
{ bg: '#0891b2', border: '#06b6d4' }, // cyan
|
{ bg: '#0891b2', border: '#06b6d4' }, // cyan
|
||||||
{ bg: '#c026d3', border: '#d946ef' }, // fuchsia
|
{ bg: '#c026d3', border: '#d946ef' }, // fuchsia
|
||||||
{ bg: '#059669', border: '#10b981' }, // emerald
|
{ bg: '#059669', border: '#10b981' }, // emerald
|
||||||
{ bg: '#7c2d12', border: '#ea580c' }, // orange
|
{ bg: '#ea580c', border: '#f97316' }, // orange
|
||||||
{ bg: '#1d4ed8', border: '#3b82f6' }, // blue
|
{ bg: '#1d4ed8', border: '#3b82f6' }, // blue
|
||||||
{ bg: '#be123c', border: '#e11d48' }, // rose
|
{ bg: '#be123c', border: '#e11d48' }, // rose
|
||||||
|
{ bg: '#4f46e5', border: '#6366f1' }, // indigo
|
||||||
|
{ bg: '#0284c7', border: '#0ea5e9' }, // sky
|
||||||
|
{ bg: '#9333ea', border: '#a855f7' }, // purple
|
||||||
|
{ bg: '#16a34a', border: '#22c55e' }, // green
|
||||||
]
|
]
|
||||||
|
|
||||||
export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheelProps) {
|
export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheelProps) {
|
||||||
const [isSpinning, setIsSpinning] = useState(false)
|
const [isSpinning, setIsSpinning] = useState(false)
|
||||||
const [rotation, setRotation] = useState(0)
|
const [rotation, setRotation] = useState(0)
|
||||||
|
const [displayedGame, setDisplayedGame] = useState<Game | null>(null)
|
||||||
|
const [spinStartTime, setSpinStartTime] = useState<number | null>(null)
|
||||||
|
const [startRotation, setStartRotation] = useState(0)
|
||||||
|
const [targetRotation, setTargetRotation] = useState(0)
|
||||||
|
|
||||||
// Размеры колеса
|
// Определяем режим отображения
|
||||||
const wheelSize = 400
|
const showText = games.length <= TEXT_THRESHOLD
|
||||||
|
const showLines = games.length <= LINES_THRESHOLD
|
||||||
|
|
||||||
|
// Размеры колеса - увеличиваем для большого количества игр
|
||||||
|
const wheelSize = games.length > 50 ? 450 : games.length > 30 ? 420 : 400
|
||||||
const centerX = wheelSize / 2
|
const centerX = wheelSize / 2
|
||||||
const centerY = wheelSize / 2
|
const centerY = wheelSize / 2
|
||||||
const radius = wheelSize / 2 - 10
|
const radius = wheelSize / 2 - 10
|
||||||
@@ -102,11 +118,16 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
|
|||||||
const fullRotations = EXTRA_ROTATIONS * 360
|
const fullRotations = EXTRA_ROTATIONS * 360
|
||||||
const finalAngle = fullRotations + (360 - targetSectorMidAngle) + baseRotation
|
const finalAngle = fullRotations + (360 - targetSectorMidAngle) + baseRotation
|
||||||
|
|
||||||
setRotation(rotation + finalAngle)
|
const newRotation = rotation + finalAngle
|
||||||
|
setStartRotation(rotation)
|
||||||
|
setTargetRotation(newRotation)
|
||||||
|
setSpinStartTime(Date.now())
|
||||||
|
setRotation(newRotation)
|
||||||
|
|
||||||
// Ждём окончания анимации
|
// Ждём окончания анимации
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsSpinning(false)
|
setIsSpinning(false)
|
||||||
|
setSpinStartTime(null)
|
||||||
onSpinComplete(resultGame)
|
onSpinComplete(resultGame)
|
||||||
}, SPIN_DURATION)
|
}, SPIN_DURATION)
|
||||||
}, [isSpinning, disabled, games, rotation, sectorAngle, onSpin, onSpinComplete])
|
}, [isSpinning, disabled, games, rotation, sectorAngle, onSpin, onSpinComplete])
|
||||||
@@ -117,13 +138,67 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
|
|||||||
return text.slice(0, maxLength - 2) + '...'
|
return text.slice(0, maxLength - 2) + '...'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Функция для вычисления игры под указателем по углу
|
||||||
|
const getGameAtAngle = useCallback((currentRotation: number) => {
|
||||||
|
if (games.length === 0) return null
|
||||||
|
const normalizedRotation = ((currentRotation % 360) + 360) % 360
|
||||||
|
const angleUnderPointer = (360 - normalizedRotation + 360) % 360
|
||||||
|
const sectorIndex = Math.floor(angleUnderPointer / sectorAngle) % games.length
|
||||||
|
return games[sectorIndex] || null
|
||||||
|
}, [games, sectorAngle])
|
||||||
|
|
||||||
|
// Вычисляем игру под указателем (статическое состояние)
|
||||||
|
const currentGameUnderPointer = useMemo(() => {
|
||||||
|
return getGameAtAngle(rotation)
|
||||||
|
}, [rotation, getGameAtAngle])
|
||||||
|
|
||||||
|
// Easing функция для имитации инерции - быстрый старт, долгое замедление
|
||||||
|
// Аппроксимирует CSS cubic-bezier(0.12, 0.9, 0.15, 1)
|
||||||
|
const easeOutExpo = useCallback((t: number): number => {
|
||||||
|
// Экспоненциальное замедление - очень быстро в начале, очень медленно в конце
|
||||||
|
return t === 1 ? 1 : 1 - Math.pow(2, -12 * t)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Отслеживаем позицию во время вращения
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSpinning || spinStartTime === null) {
|
||||||
|
// Когда не крутится - показываем текущую игру под указателем
|
||||||
|
if (currentGameUnderPointer) {
|
||||||
|
setDisplayedGame(currentGameUnderPointer)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDelta = targetRotation - startRotation
|
||||||
|
|
||||||
|
const updateDisplayedGame = () => {
|
||||||
|
const elapsed = Date.now() - spinStartTime
|
||||||
|
const progress = Math.min(elapsed / SPIN_DURATION, 1)
|
||||||
|
const easedProgress = easeOutExpo(progress)
|
||||||
|
|
||||||
|
// Вычисляем текущий угол на основе прогресса анимации
|
||||||
|
const currentAngle = startRotation + (totalDelta * easedProgress)
|
||||||
|
const game = getGameAtAngle(currentAngle)
|
||||||
|
|
||||||
|
if (game) {
|
||||||
|
setDisplayedGame(game)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем каждые 30мс для плавности
|
||||||
|
const interval = setInterval(updateDisplayedGame, 30)
|
||||||
|
updateDisplayedGame() // Сразу обновляем
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [isSpinning, spinStartTime, startRotation, targetRotation, getGameAtAngle, currentGameUnderPointer, easeOutExpo])
|
||||||
|
|
||||||
// Мемоизируем секторы для производительности
|
// Мемоизируем секторы для производительности
|
||||||
const sectors = useMemo(() => {
|
const sectors = useMemo(() => {
|
||||||
return games.map((game, index) => {
|
return games.map((game, index) => {
|
||||||
const color = SECTOR_COLORS[index % SECTOR_COLORS.length]
|
const color = SECTOR_COLORS[index % SECTOR_COLORS.length]
|
||||||
const path = createSectorPath(index, games.length)
|
const path = createSectorPath(index, games.length)
|
||||||
const textPos = getTextPosition(index, games.length)
|
const textPos = getTextPosition(index, games.length)
|
||||||
const maxTextLength = games.length > 8 ? 10 : games.length > 5 ? 14 : 18
|
const maxTextLength = games.length > 12 ? 8 : games.length > 8 ? 10 : games.length > 5 ? 14 : 18
|
||||||
|
|
||||||
return { game, color, path, textPos, maxTextLength }
|
return { game, color, path, textPos, maxTextLength }
|
||||||
})
|
})
|
||||||
@@ -213,7 +288,8 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
|
|||||||
transform: `rotate(${rotation}deg)`,
|
transform: `rotate(${rotation}deg)`,
|
||||||
transitionProperty: isSpinning ? 'transform' : 'none',
|
transitionProperty: isSpinning ? 'transform' : 'none',
|
||||||
transitionDuration: isSpinning ? `${SPIN_DURATION}ms` : '0ms',
|
transitionDuration: isSpinning ? `${SPIN_DURATION}ms` : '0ms',
|
||||||
transitionTimingFunction: 'cubic-bezier(0.17, 0.67, 0.12, 0.99)',
|
// Инерционное вращение: быстрый старт, долгое плавное замедление
|
||||||
|
transitionTimingFunction: 'cubic-bezier(0.12, 0.9, 0.15, 1)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
@@ -230,38 +306,42 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
|
|||||||
<path
|
<path
|
||||||
d={path}
|
d={path}
|
||||||
fill={color.bg}
|
fill={color.bg}
|
||||||
stroke={color.border}
|
stroke={showLines ? color.border : 'transparent'}
|
||||||
strokeWidth="2"
|
strokeWidth={showLines ? "1" : "0"}
|
||||||
filter="url(#sectorShadow)"
|
filter="url(#sectorShadow)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Текст названия игры */}
|
{/* Текст названия игры - только для небольшого количества */}
|
||||||
<text
|
{showText && (
|
||||||
x={textPos.x}
|
<text
|
||||||
y={textPos.y}
|
x={textPos.x}
|
||||||
transform={`rotate(${textPos.rotation}, ${textPos.x}, ${textPos.y})`}
|
y={textPos.y}
|
||||||
textAnchor="middle"
|
transform={`rotate(${textPos.rotation}, ${textPos.x}, ${textPos.y})`}
|
||||||
dominantBaseline="middle"
|
textAnchor="middle"
|
||||||
fill="white"
|
dominantBaseline="middle"
|
||||||
fontSize={games.length > 10 ? "10" : games.length > 6 ? "11" : "13"}
|
fill="white"
|
||||||
fontWeight="bold"
|
fontSize={games.length > 12 ? "9" : games.length > 8 ? "10" : games.length > 6 ? "11" : "13"}
|
||||||
style={{
|
fontWeight="bold"
|
||||||
textShadow: '0 1px 3px rgba(0,0,0,0.8)',
|
style={{
|
||||||
pointerEvents: 'none',
|
textShadow: '0 1px 3px rgba(0,0,0,0.8)',
|
||||||
}}
|
pointerEvents: 'none',
|
||||||
>
|
}}
|
||||||
{truncateText(game.title, maxTextLength)}
|
>
|
||||||
</text>
|
{truncateText(game.title, maxTextLength)}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Разделительная линия */}
|
{/* Разделительная линия - только для среднего количества */}
|
||||||
<line
|
{showLines && (
|
||||||
x1={centerX}
|
<line
|
||||||
y1={centerY}
|
x1={centerX}
|
||||||
x2={centerX + radius * Math.cos((index * sectorAngle - 90) * Math.PI / 180)}
|
y1={centerY}
|
||||||
y2={centerY + radius * Math.sin((index * sectorAngle - 90) * Math.PI / 180)}
|
x2={centerX + radius * Math.cos((index * sectorAngle - 90) * Math.PI / 180)}
|
||||||
stroke="rgba(255,255,255,0.3)"
|
y2={centerY + radius * Math.sin((index * sectorAngle - 90) * Math.PI / 180)}
|
||||||
strokeWidth="1"
|
stroke="rgba(255,255,255,0.2)"
|
||||||
/>
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -322,6 +402,21 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Название текущей игры (для большого количества) */}
|
||||||
|
{!showText && (
|
||||||
|
<div className="glass rounded-xl px-6 py-3 min-w-[280px] text-center">
|
||||||
|
<p className="text-xs text-gray-500 mb-1">
|
||||||
|
{games.length} игр в колесе
|
||||||
|
</p>
|
||||||
|
<p className={`
|
||||||
|
font-semibold transition-all duration-100 truncate max-w-[280px]
|
||||||
|
${isSpinning ? 'text-neon-400 animate-pulse' : 'text-white'}
|
||||||
|
`}>
|
||||||
|
{displayedGame?.title || 'Крутите колесо!'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Подсказка */}
|
{/* Подсказка */}
|
||||||
<p className={`
|
<p className={`
|
||||||
text-sm transition-all duration-300
|
text-sm transition-all duration-300
|
||||||
|
|||||||
100
frontend/src/components/TelegramBotBanner.tsx
Normal file
100
frontend/src/components/TelegramBotBanner.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -234,7 +234,13 @@ export function Layout() {
|
|||||||
Игровой Марафон © {new Date().getFullYear()}
|
Игровой Марафон © {new Date().getFullYear()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
<div className="flex items-center gap-6 text-sm">
|
||||||
|
<Link to="/terms" className="text-gray-500 hover:text-gray-300 transition-colors">
|
||||||
|
Правила
|
||||||
|
</Link>
|
||||||
|
<Link to="/privacy" className="text-gray-500 hover:text-gray-300 transition-colors">
|
||||||
|
Конфиденциальность
|
||||||
|
</Link>
|
||||||
<span className="text-neon-500/50">v1.0</span>
|
<span className="text-neon-500/50">v1.0</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,18 +67,28 @@ export const NeonButton = forwardRef<HTMLButtonElement, NeonButtonProps>(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeClasses = {
|
|
||||||
sm: 'px-3 py-1.5 text-sm gap-1.5',
|
|
||||||
md: 'px-4 py-2.5 text-base gap-2',
|
|
||||||
lg: 'px-6 py-3 text-lg gap-2.5',
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconSizes = {
|
const iconSizes = {
|
||||||
sm: 'w-4 h-4',
|
sm: 'w-4 h-4',
|
||||||
md: 'w-5 h-5',
|
md: 'w-5 h-5',
|
||||||
lg: 'w-6 h-6',
|
lg: 'w-6 h-6',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isIconOnly = icon && !children
|
||||||
|
|
||||||
|
const sizeClassesWithText = {
|
||||||
|
sm: 'px-3 py-1.5 text-sm gap-1.5',
|
||||||
|
md: 'px-4 py-2.5 text-base gap-2',
|
||||||
|
lg: 'px-6 py-3 text-lg gap-2.5',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClassesIconOnly = {
|
||||||
|
sm: 'p-2 text-sm',
|
||||||
|
md: 'p-2.5 text-base',
|
||||||
|
lg: 'p-3 text-lg',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = isIconOnly ? sizeClassesIconOnly : sizeClassesWithText
|
||||||
|
|
||||||
const colors = colorMap[color]
|
const colors = colorMap[color]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -118,13 +128,9 @@ export const NeonButton = forwardRef<HTMLButtonElement, NeonButtonProps>(
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{isLoading && <Loader2 className={clsx(iconSizes[size], 'animate-spin')} />}
|
{isLoading && <Loader2 className={clsx(iconSizes[size], 'animate-spin')} />}
|
||||||
{!isLoading && icon && iconPosition === 'left' && (
|
{!isLoading && icon && iconPosition === 'left' && icon}
|
||||||
<span className={iconSizes[size]}>{icon}</span>
|
|
||||||
)}
|
|
||||||
{children}
|
{children}
|
||||||
{!isLoading && icon && iconPosition === 'right' && (
|
{!isLoading && icon && iconPosition === 'right' && icon}
|
||||||
<span className={iconSizes[size]}>{icon}</span>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useToast } from '@/store/toast'
|
|||||||
import {
|
import {
|
||||||
ArrowLeft, Loader2, ExternalLink, Image, MessageSquare,
|
ArrowLeft, Loader2, ExternalLink, Image, MessageSquare,
|
||||||
ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle,
|
ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle,
|
||||||
Send, Flag, Gamepad2, Zap, Trophy
|
Send, Flag, Gamepad2, Zap, Trophy, Download, ChevronLeft, ChevronRight, X
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
export function AssignmentDetailPage() {
|
export function AssignmentDetailPage() {
|
||||||
@@ -23,11 +23,30 @@ export function AssignmentDetailPage() {
|
|||||||
const [proofMediaBlobUrl, setProofMediaBlobUrl] = useState<string | null>(null)
|
const [proofMediaBlobUrl, setProofMediaBlobUrl] = useState<string | null>(null)
|
||||||
const [proofMediaType, setProofMediaType] = useState<'image' | 'video' | null>(null)
|
const [proofMediaType, setProofMediaType] = useState<'image' | 'video' | null>(null)
|
||||||
|
|
||||||
|
// Multiple proof files
|
||||||
|
const [proofFiles, setProofFiles] = useState<Array<{ id: number; url: string; type: 'image' | 'video' }>>([])
|
||||||
|
|
||||||
|
// Bonus proof media
|
||||||
|
const [bonusProofMedia, setBonusProofMedia] = useState<Record<number, { url: string; type: 'image' | 'video' }>>({})
|
||||||
|
|
||||||
|
// Bonus proof files (multiple)
|
||||||
|
const [bonusProofFiles, setBonusProofFiles] = useState<Record<number, Array<{ id: number; url: string; type: 'image' | 'video' }>>>({})
|
||||||
|
|
||||||
|
// Lightbox state
|
||||||
|
const [lightboxOpen, setLightboxOpen] = useState(false)
|
||||||
|
const [lightboxIndex, setLightboxIndex] = useState(0)
|
||||||
|
const [lightboxItems, setLightboxItems] = useState<Array<{ url: string; type: 'image' | 'video' }>>([])
|
||||||
|
|
||||||
// Dispute creation
|
// Dispute creation
|
||||||
const [showDisputeForm, setShowDisputeForm] = useState(false)
|
const [showDisputeForm, setShowDisputeForm] = useState(false)
|
||||||
const [disputeReason, setDisputeReason] = useState('')
|
const [disputeReason, setDisputeReason] = useState('')
|
||||||
const [isCreatingDispute, setIsCreatingDispute] = useState(false)
|
const [isCreatingDispute, setIsCreatingDispute] = useState(false)
|
||||||
|
|
||||||
|
// Bonus dispute creation
|
||||||
|
const [activeBonusDisputeId, setActiveBonusDisputeId] = useState<number | null>(null)
|
||||||
|
const [bonusDisputeReason, setBonusDisputeReason] = useState('')
|
||||||
|
const [isCreatingBonusDispute, setIsCreatingBonusDispute] = useState(false)
|
||||||
|
|
||||||
// Comment
|
// Comment
|
||||||
const [commentText, setCommentText] = useState('')
|
const [commentText, setCommentText] = useState('')
|
||||||
const [isAddingComment, setIsAddingComment] = useState(false)
|
const [isAddingComment, setIsAddingComment] = useState(false)
|
||||||
@@ -38,10 +57,24 @@ export function AssignmentDetailPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAssignment()
|
loadAssignment()
|
||||||
return () => {
|
return () => {
|
||||||
// Cleanup blob URL on unmount
|
// Cleanup blob URLs on unmount
|
||||||
if (proofMediaBlobUrl) {
|
if (proofMediaBlobUrl) {
|
||||||
URL.revokeObjectURL(proofMediaBlobUrl)
|
URL.revokeObjectURL(proofMediaBlobUrl)
|
||||||
}
|
}
|
||||||
|
proofFiles.forEach(file => {
|
||||||
|
URL.revokeObjectURL(file.url)
|
||||||
|
})
|
||||||
|
Object.values(bonusProofMedia).forEach(media => {
|
||||||
|
URL.revokeObjectURL(media.url)
|
||||||
|
})
|
||||||
|
Object.values(bonusProofFiles).forEach(files => {
|
||||||
|
files.forEach(file => {
|
||||||
|
URL.revokeObjectURL(file.url)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
lightboxItems.forEach(item => {
|
||||||
|
URL.revokeObjectURL(item.url)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
@@ -53,8 +86,20 @@ export function AssignmentDetailPage() {
|
|||||||
const data = await assignmentsApi.getDetail(parseInt(id))
|
const data = await assignmentsApi.getDetail(parseInt(id))
|
||||||
setAssignment(data)
|
setAssignment(data)
|
||||||
|
|
||||||
// Load proof media if exists
|
// Load proof files if exists (new multi-file support)
|
||||||
if (data.proof_image_url) {
|
if (data.proof_files && data.proof_files.length > 0) {
|
||||||
|
const files: Array<{ id: number; url: string; type: 'image' | 'video' }> = []
|
||||||
|
for (const proofFile of data.proof_files) {
|
||||||
|
try {
|
||||||
|
const { url, type } = await assignmentsApi.getProofFileMediaUrl(parseInt(id), proofFile.id)
|
||||||
|
files.push({ id: proofFile.id, url, type })
|
||||||
|
} catch {
|
||||||
|
// Ignore error, file just won't show
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setProofFiles(files)
|
||||||
|
} else if (data.proof_image_url) {
|
||||||
|
// Legacy: Load single proof media if exists
|
||||||
try {
|
try {
|
||||||
const { url, type } = await assignmentsApi.getProofMediaUrl(parseInt(id))
|
const { url, type } = await assignmentsApi.getProofMediaUrl(parseInt(id))
|
||||||
setProofMediaBlobUrl(url)
|
setProofMediaBlobUrl(url)
|
||||||
@@ -63,6 +108,39 @@ export function AssignmentDetailPage() {
|
|||||||
// Ignore error, media just won't show
|
// Ignore error, media just won't show
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load bonus proof files for playthrough
|
||||||
|
if (data.is_playthrough && data.bonus_challenges) {
|
||||||
|
const bonusMedia: Record<number, { url: string; type: 'image' | 'video' }> = {}
|
||||||
|
const bonusFiles: Record<number, Array<{ id: number; url: string; type: 'image' | 'video' }>> = {}
|
||||||
|
|
||||||
|
for (const bonus of data.bonus_challenges) {
|
||||||
|
// New multi-file support
|
||||||
|
if (bonus.proof_files && bonus.proof_files.length > 0) {
|
||||||
|
const files: Array<{ id: number; url: string; type: 'image' | 'video' }> = []
|
||||||
|
for (const proofFile of bonus.proof_files) {
|
||||||
|
try {
|
||||||
|
const { url, type } = await assignmentsApi.getBonusProofFileMediaUrl(parseInt(id), bonus.id, proofFile.id)
|
||||||
|
files.push({ id: proofFile.id, url, type })
|
||||||
|
} catch {
|
||||||
|
// Ignore error, file just won't show
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bonusFiles[bonus.id] = files
|
||||||
|
} else if (bonus.proof_image_url) {
|
||||||
|
// Legacy: single file
|
||||||
|
try {
|
||||||
|
const { url, type } = await assignmentsApi.getBonusProofMediaUrl(parseInt(id), bonus.id)
|
||||||
|
bonusMedia[bonus.id] = { url, type }
|
||||||
|
} catch {
|
||||||
|
// Ignore error, media just won't show
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setBonusProofMedia(bonusMedia)
|
||||||
|
setBonusProofFiles(bonusFiles)
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
setError(error.response?.data?.detail || 'Не удалось загрузить данные')
|
setError(error.response?.data?.detail || 'Не удалось загрузить данные')
|
||||||
@@ -88,6 +166,37 @@ export function AssignmentDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCreateBonusDispute = async (bonusId: number) => {
|
||||||
|
if (!bonusDisputeReason.trim()) return
|
||||||
|
|
||||||
|
setIsCreatingBonusDispute(true)
|
||||||
|
try {
|
||||||
|
await assignmentsApi.createBonusDispute(bonusId, bonusDisputeReason)
|
||||||
|
setBonusDisputeReason('')
|
||||||
|
setActiveBonusDisputeId(null)
|
||||||
|
await loadAssignment()
|
||||||
|
toast.success('Оспаривание бонуса создано')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось создать оспаривание')
|
||||||
|
} finally {
|
||||||
|
setIsCreatingBonusDispute(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBonusVote = async (disputeId: number, vote: boolean) => {
|
||||||
|
setIsVoting(true)
|
||||||
|
try {
|
||||||
|
await assignmentsApi.vote(disputeId, vote)
|
||||||
|
await loadAssignment()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось проголосовать')
|
||||||
|
} finally {
|
||||||
|
setIsVoting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleVote = async (vote: boolean) => {
|
const handleVote = async (vote: boolean) => {
|
||||||
if (!assignment?.dispute) return
|
if (!assignment?.dispute) return
|
||||||
|
|
||||||
@@ -142,6 +251,24 @@ export function AssignmentDetailPage() {
|
|||||||
return `${hours}ч ${minutes}м`
|
return `${hours}ч ${minutes}м`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openLightbox = (items: Array<{ url: string; type: 'image' | 'video' }>, index: number) => {
|
||||||
|
setLightboxItems(items)
|
||||||
|
setLightboxIndex(index)
|
||||||
|
setLightboxOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeLightbox = () => {
|
||||||
|
setLightboxOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLightboxItem = () => {
|
||||||
|
setLightboxIndex((prev) => (prev + 1) % lightboxItems.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevLightboxItem = () => {
|
||||||
|
setLightboxIndex((prev) => (prev - 1 + lightboxItems.length) % lightboxItems.length)
|
||||||
|
}
|
||||||
|
|
||||||
const getStatusConfig = (status: string) => {
|
const getStatusConfig = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'completed':
|
case 'completed':
|
||||||
@@ -215,39 +342,76 @@ export function AssignmentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Challenge info */}
|
{/* Challenge/Playthrough info */}
|
||||||
<GlassCard variant="neon">
|
<GlassCard variant="neon">
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 border border-neon-500/20 flex items-center justify-center">
|
<div className={`w-14 h-14 rounded-xl border flex items-center justify-center ${
|
||||||
<Gamepad2 className="w-7 h-7 text-neon-400" />
|
assignment.is_playthrough
|
||||||
|
? 'bg-gradient-to-br from-accent-500/20 to-purple-500/20 border-accent-500/20'
|
||||||
|
: 'bg-gradient-to-br from-neon-500/20 to-accent-500/20 border-neon-500/20'
|
||||||
|
}`}>
|
||||||
|
<Gamepad2 className={`w-7 h-7 ${assignment.is_playthrough ? 'text-accent-400' : 'text-neon-400'}`} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-400 text-sm">{assignment.challenge.game.title}</p>
|
<p className="text-gray-400 text-sm">
|
||||||
<h2 className="text-xl font-bold text-white">{assignment.challenge.title}</h2>
|
{assignment.is_playthrough ? assignment.game?.title : assignment.challenge?.game.title}
|
||||||
|
</p>
|
||||||
|
<h2 className="text-xl font-bold text-white">
|
||||||
|
{assignment.is_playthrough ? 'Прохождение игры' : assignment.challenge?.title}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`px-3 py-1.5 rounded-full text-sm font-medium border flex items-center gap-1.5 ${status.color}`}>
|
<div className="flex flex-col items-end gap-2">
|
||||||
{status.icon}
|
{assignment.is_playthrough && (
|
||||||
{status.text}
|
<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>
|
||||||
|
)}
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-300 mb-4">{assignment.challenge.description}</p>
|
<p className="text-gray-300 mb-4">
|
||||||
|
{assignment.is_playthrough
|
||||||
|
? assignment.playthrough_info?.description
|
||||||
|
: assignment.challenge?.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30 flex items-center gap-1.5">
|
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30 flex items-center gap-1.5">
|
||||||
<Trophy className="w-4 h-4" />
|
<Trophy className="w-4 h-4" />
|
||||||
+{assignment.challenge.points} очков
|
+{assignment.is_playthrough
|
||||||
|
? assignment.playthrough_info?.points
|
||||||
|
: assignment.challenge?.points} очков
|
||||||
</span>
|
</span>
|
||||||
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
|
{!assignment.is_playthrough && assignment.challenge && (
|
||||||
{assignment.challenge.difficulty}
|
<>
|
||||||
</span>
|
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
|
||||||
{assignment.challenge.estimated_time && (
|
{assignment.challenge.difficulty}
|
||||||
<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">
|
</span>
|
||||||
<Clock className="w-4 h-4" />
|
{assignment.challenge.estimated_time && (
|
||||||
~{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">
|
||||||
</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
@@ -271,6 +435,224 @@ export function AssignmentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|
||||||
|
{/* Bonus challenges for playthrough */}
|
||||||
|
{assignment.is_playthrough && assignment.bonus_challenges && assignment.bonus_challenges.length > 0 && (
|
||||||
|
<GlassCard>
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
|
||||||
|
<Trophy className="w-5 h-5 text-accent-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">Бонусные челленджи</h3>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Выполнено: {assignment.bonus_challenges.filter((b: { status: string }) => b.status === 'completed').length} из {assignment.bonus_challenges.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{assignment.bonus_challenges.map((bonus) => (
|
||||||
|
<div
|
||||||
|
key={bonus.id}
|
||||||
|
className={`p-4 rounded-xl border ${
|
||||||
|
bonus.dispute ? 'bg-yellow-500/10 border-yellow-500/30' :
|
||||||
|
bonus.status === 'completed'
|
||||||
|
? 'bg-green-500/10 border-green-500/30'
|
||||||
|
: 'bg-dark-700/50 border-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
{bonus.dispute ? (
|
||||||
|
<AlertTriangle className="w-4 h-4 text-yellow-400" />
|
||||||
|
) : bonus.status === 'completed' ? (
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||||
|
) : null}
|
||||||
|
<span className="text-white font-medium">{bonus.challenge.title}</span>
|
||||||
|
{bonus.dispute && (
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||||
|
bonus.dispute.status === 'open' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||||
|
bonus.dispute.status === 'valid' ? 'bg-green-500/20 text-green-400' :
|
||||||
|
'bg-red-500/20 text-red-400'
|
||||||
|
}`}>
|
||||||
|
{bonus.dispute.status === 'open' ? 'Оспаривается' :
|
||||||
|
bonus.dispute.status === 'valid' ? 'Валидно' : 'Невалидно'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 text-sm">{bonus.challenge.description}</p>
|
||||||
|
{bonus.status === 'completed' && (bonus.proof_url || bonus.proof_image_url || bonus.proof_comment || bonusProofFiles[bonus.id]) && (
|
||||||
|
<div className="mt-2 text-xs space-y-2">
|
||||||
|
{/* Multiple proof files */}
|
||||||
|
{bonusProofFiles[bonus.id] && bonusProofFiles[bonus.id].length > 0 && (
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{bonusProofFiles[bonus.id].map((file, index) => (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
className="relative rounded-lg overflow-hidden border border-dark-600 cursor-pointer hover:border-neon-500/50 transition-all w-24 h-24"
|
||||||
|
onClick={() => openLightbox(bonusProofFiles[bonus.id], index)}
|
||||||
|
>
|
||||||
|
{file.type === 'video' ? (
|
||||||
|
<div className="relative w-full h-full">
|
||||||
|
<video
|
||||||
|
src={file.url}
|
||||||
|
className="w-full h-full object-cover bg-dark-900"
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-neon-500/80 flex items-center justify-center">
|
||||||
|
<div className="w-0 h-0 border-l-4 border-l-white border-y-3 border-y-transparent ml-0.5"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={file.url}
|
||||||
|
alt={`Proof ${index + 1}`}
|
||||||
|
className="w-full h-full object-cover bg-dark-900"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Legacy: single proof media */}
|
||||||
|
{(!bonusProofFiles[bonus.id] || bonusProofFiles[bonus.id].length === 0) && bonusProofMedia[bonus.id] && (
|
||||||
|
<div className="rounded-lg overflow-hidden border border-dark-600 max-w-xs">
|
||||||
|
{bonusProofMedia[bonus.id].type === 'video' ? (
|
||||||
|
<video
|
||||||
|
src={bonusProofMedia[bonus.id].url}
|
||||||
|
controls
|
||||||
|
className="w-full max-h-32 bg-dark-900"
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => openLightbox([bonusProofMedia[bonus.id]], 0)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={bonusProofMedia[bonus.id].url}
|
||||||
|
alt="Proof"
|
||||||
|
className="w-full h-auto max-h-32 object-cover hover:opacity-80 transition-opacity"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bonus.proof_url && (
|
||||||
|
<a
|
||||||
|
href={bonus.proof_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-neon-400 hover:underline flex items-center gap-1 break-all"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3 shrink-0" />
|
||||||
|
{bonus.proof_url}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{bonus.proof_comment && (
|
||||||
|
<p className="text-gray-400">"{bonus.proof_comment}"</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bonus dispute form */}
|
||||||
|
{activeBonusDisputeId === bonus.id && (
|
||||||
|
<div className="mt-3 p-3 bg-red-500/10 rounded-lg border border-red-500/30">
|
||||||
|
<textarea
|
||||||
|
className="input w-full min-h-[80px] resize-none mb-2 text-sm"
|
||||||
|
placeholder="Причина оспаривания (минимум 10 символов)..."
|
||||||
|
value={bonusDisputeReason}
|
||||||
|
onChange={(e) => setBonusDisputeReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="px-3 py-1.5 text-sm bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 disabled:opacity-50"
|
||||||
|
onClick={() => handleCreateBonusDispute(bonus.id)}
|
||||||
|
disabled={bonusDisputeReason.trim().length < 10 || isCreatingBonusDispute}
|
||||||
|
>
|
||||||
|
{isCreatingBonusDispute ? 'Создание...' : 'Оспорить'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-3 py-1.5 text-sm bg-dark-600 text-gray-300 rounded-lg hover:bg-dark-500"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveBonusDisputeId(null)
|
||||||
|
setBonusDisputeReason('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bonus dispute info */}
|
||||||
|
{bonus.dispute && (
|
||||||
|
<div className="mt-3 p-3 bg-yellow-500/10 rounded-lg border border-yellow-500/30">
|
||||||
|
<p className="text-xs text-gray-400 mb-1">
|
||||||
|
Оспорил: <span className="text-white">{bonus.dispute.raised_by.nickname}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-white mb-2">{bonus.dispute.reason}</p>
|
||||||
|
|
||||||
|
{bonus.dispute.status === 'open' && (
|
||||||
|
<div className="flex items-center gap-4 mt-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ThumbsUp className="w-3 h-3 text-green-400" />
|
||||||
|
<span className="text-green-400 text-sm font-medium">{bonus.dispute.votes_valid}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ThumbsDown className="w-3 h-3 text-red-400" />
|
||||||
|
<span className="text-red-400 text-sm font-medium">{bonus.dispute.votes_invalid}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 ml-auto">
|
||||||
|
<button
|
||||||
|
className={`p-1.5 rounded ${bonus.dispute.my_vote === true ? 'bg-green-500/30' : 'bg-dark-600 hover:bg-dark-500'}`}
|
||||||
|
onClick={() => handleBonusVote(bonus.dispute!.id, true)}
|
||||||
|
disabled={isVoting}
|
||||||
|
>
|
||||||
|
<ThumbsUp className="w-3 h-3 text-green-400" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`p-1.5 rounded ${bonus.dispute.my_vote === false ? 'bg-red-500/30' : 'bg-dark-600 hover:bg-dark-500'}`}
|
||||||
|
onClick={() => handleBonusVote(bonus.dispute!.id, false)}
|
||||||
|
disabled={isVoting}
|
||||||
|
>
|
||||||
|
<ThumbsDown className="w-3 h-3 text-red-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0 ml-3 flex flex-col items-end gap-2">
|
||||||
|
{bonus.status === 'completed' ? (
|
||||||
|
<span className="text-green-400 font-semibold">+{bonus.points_earned}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-500">+{bonus.challenge.points}</span>
|
||||||
|
)}
|
||||||
|
{/* Dispute button for bonus */}
|
||||||
|
{bonus.can_dispute && !bonus.dispute && activeBonusDisputeId !== bonus.id && (
|
||||||
|
<button
|
||||||
|
className="text-xs px-2 py-1 text-red-400 hover:bg-red-500/10 rounded flex items-center gap-1"
|
||||||
|
onClick={() => setActiveBonusDisputeId(bonus.id)}
|
||||||
|
>
|
||||||
|
<Flag className="w-3 h-3" />
|
||||||
|
Оспорить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Proof section */}
|
{/* Proof section */}
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
@@ -283,8 +665,47 @@ export function AssignmentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Proof media (image or video) */}
|
{/* Proof files gallery (multiple proofs) */}
|
||||||
{assignment.proof_image_url && (
|
{proofFiles.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{proofFiles.map((file, index) => (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
className="relative rounded-xl overflow-hidden border border-dark-600 cursor-pointer hover:border-neon-500/50 transition-all group"
|
||||||
|
onClick={() => openLightbox(proofFiles, index)}
|
||||||
|
>
|
||||||
|
{file.type === 'video' ? (
|
||||||
|
<div className="relative">
|
||||||
|
<video
|
||||||
|
src={file.url}
|
||||||
|
className="w-full h-48 object-cover bg-dark-900"
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50 group-hover:bg-black/30 transition-all">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-neon-500/80 flex items-center justify-center">
|
||||||
|
<div className="w-0 h-0 border-l-8 border-l-white border-y-6 border-y-transparent ml-1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={file.url}
|
||||||
|
alt={`Proof ${index + 1}`}
|
||||||
|
className="w-full h-48 object-cover bg-dark-900 group-hover:opacity-90 transition-opacity"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="absolute top-2 right-2 px-2 py-1 bg-dark-900/80 rounded text-xs text-gray-300">
|
||||||
|
{index + 1}/{proofFiles.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Legacy: Single proof media (for backwards compatibility) */}
|
||||||
|
{proofFiles.length === 0 && assignment.proof_image_url && (
|
||||||
<div className="mb-4 rounded-xl overflow-hidden border border-dark-600">
|
<div className="mb-4 rounded-xl overflow-hidden border border-dark-600">
|
||||||
{proofMediaBlobUrl ? (
|
{proofMediaBlobUrl ? (
|
||||||
proofMediaType === 'video' ? (
|
proofMediaType === 'video' ? (
|
||||||
@@ -295,11 +716,16 @@ export function AssignmentDetailPage() {
|
|||||||
preload="metadata"
|
preload="metadata"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<button
|
||||||
src={proofMediaBlobUrl}
|
onClick={() => openLightbox([{ url: proofMediaBlobUrl, type: 'image' }], 0)}
|
||||||
alt="Proof"
|
className="w-full"
|
||||||
className="w-full max-h-96 object-contain bg-dark-900"
|
>
|
||||||
/>
|
<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">
|
<div className="w-full h-48 bg-dark-900 flex items-center justify-center">
|
||||||
@@ -332,7 +758,7 @@ export function AssignmentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!assignment.proof_image_url && !assignment.proof_url && (
|
{proofFiles.length === 0 && !assignment.proof_image_url && !assignment.proof_url && (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-dark-700 flex items-center justify-center">
|
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-dark-700 flex items-center justify-center">
|
||||||
<Image className="w-6 h-6 text-gray-600" />
|
<Image className="w-6 h-6 text-gray-600" />
|
||||||
@@ -548,6 +974,69 @@ export function AssignmentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Lightbox modal */}
|
||||||
|
{lightboxOpen && lightboxItems.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
|
||||||
|
onClick={closeLightbox}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-dark-700/80 hover:bg-dark-600 flex items-center justify-center text-gray-400 hover:text-white transition-all z-10"
|
||||||
|
onClick={closeLightbox}
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{lightboxItems.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="absolute left-4 w-12 h-12 rounded-full bg-dark-700/80 hover:bg-dark-600 flex items-center justify-center text-gray-400 hover:text-white transition-all z-10"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
prevLightboxItem()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-8 h-8" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="absolute right-4 w-12 h-12 rounded-full bg-dark-700/80 hover:bg-dark-600 flex items-center justify-center text-gray-400 hover:text-white transition-all z-10"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
nextLightboxItem()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-8 h-8" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 px-4 py-2 bg-dark-700/80 rounded-full text-white text-sm z-10">
|
||||||
|
{lightboxIndex + 1} / {lightboxItems.length}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="max-w-7xl max-h-[90vh] w-full h-full flex items-center justify-center p-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{lightboxItems[lightboxIndex].type === 'video' ? (
|
||||||
|
<video
|
||||||
|
src={lightboxItems[lightboxIndex].url}
|
||||||
|
controls
|
||||||
|
autoPlay
|
||||||
|
className="max-w-full max-h-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={lightboxItems[lightboxIndex].url}
|
||||||
|
alt="Proof"
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { marathonsApi } from '@/api'
|
import { marathonsApi } from '@/api'
|
||||||
import { NeonButton, Input, GlassCard } from '@/components/ui'
|
import { NeonButton, Input, GlassCard } from '@/components/ui'
|
||||||
import { Globe, Lock, Users, UserCog, ArrowLeft, Gamepad2, AlertCircle, Sparkles, Calendar, Clock } from 'lucide-react'
|
import { Globe, Lock, Users, UserCog, ArrowLeft, Gamepad2, AlertCircle, Sparkles, Calendar, Clock, Camera, Trash2 } from 'lucide-react'
|
||||||
import type { GameProposalMode } from '@/types'
|
import type { GameProposalMode } from '@/types'
|
||||||
|
import { useToast } from '@/store/toast'
|
||||||
|
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
title: z.string().min(1, 'Название обязательно').max(100),
|
title: z.string().min(1, 'Название обязательно').max(100),
|
||||||
@@ -21,8 +22,12 @@ type CreateForm = z.infer<typeof createSchema>
|
|||||||
|
|
||||||
export function CreateMarathonPage() {
|
export function CreateMarathonPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const toast = useToast()
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [coverFile, setCoverFile] = useState<File | null>(null)
|
||||||
|
const [coverPreview, setCoverPreview] = useState<string | null>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -42,6 +47,38 @@ export function CreateMarathonPage() {
|
|||||||
const isPublic = watch('is_public')
|
const isPublic = watch('is_public')
|
||||||
const gameProposalMode = watch('game_proposal_mode')
|
const gameProposalMode = watch('game_proposal_mode')
|
||||||
|
|
||||||
|
const handleCoverClick = () => {
|
||||||
|
fileInputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCoverChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
toast.error('Файл должен быть изображением')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
toast.error('Максимальный размер файла 5 МБ')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCoverFile(file)
|
||||||
|
setCoverPreview(URL.createObjectURL(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveCover = () => {
|
||||||
|
setCoverFile(null)
|
||||||
|
if (coverPreview) {
|
||||||
|
URL.revokeObjectURL(coverPreview)
|
||||||
|
}
|
||||||
|
setCoverPreview(null)
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const onSubmit = async (data: CreateForm) => {
|
const onSubmit = async (data: CreateForm) => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -54,6 +91,16 @@ export function CreateMarathonPage() {
|
|||||||
is_public: data.is_public,
|
is_public: data.is_public,
|
||||||
game_proposal_mode: data.game_proposal_mode as GameProposalMode,
|
game_proposal_mode: data.game_proposal_mode as GameProposalMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Upload cover if selected
|
||||||
|
if (coverFile) {
|
||||||
|
try {
|
||||||
|
await marathonsApi.uploadCover(marathon.id, coverFile)
|
||||||
|
} catch {
|
||||||
|
toast.warning('Марафон создан, но не удалось загрузить обложку')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
navigate(`/marathons/${marathon.id}/lobby`)
|
navigate(`/marathons/${marathon.id}/lobby`)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const apiError = err as { response?: { data?: { detail?: string } } }
|
const apiError = err as { response?: { data?: { detail?: string } } }
|
||||||
@@ -94,6 +141,57 @@ export function CreateMarathonPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Cover Image */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-3">
|
||||||
|
Обложка (необязательно)
|
||||||
|
</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCoverClick}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="relative w-full h-40 rounded-xl overflow-hidden bg-dark-700 border-2 border-dashed border-dark-500 hover:border-neon-500/50 transition-all"
|
||||||
|
>
|
||||||
|
{coverPreview ? (
|
||||||
|
<img
|
||||||
|
src={coverPreview}
|
||||||
|
alt="Обложка марафона"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex flex-col items-center justify-center text-gray-500">
|
||||||
|
<Camera className="w-8 h-8 mb-2" />
|
||||||
|
<span className="text-sm">Нажмите для загрузки</span>
|
||||||
|
<span className="text-xs text-gray-600 mt-1">JPG, PNG до 5 МБ</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{coverPreview && (
|
||||||
|
<div className="absolute inset-0 bg-dark-900/70 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Camera className="w-6 h-6 text-neon-500" />
|
||||||
|
<span className="ml-2 text-white text-sm">Изменить</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{coverPreview && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemoveCover}
|
||||||
|
className="absolute top-2 right-2 p-2 rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleCoverChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Basic info */}
|
{/* Basic info */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||||
import { marathonsApi, gamesApi } from '@/api'
|
import { marathonsApi, gamesApi } from '@/api'
|
||||||
import type { Marathon, Game, Challenge, ChallengePreview } from '@/types'
|
import type { Marathon, Game, Challenge, ChallengePreview, GameType, ProofType } from '@/types'
|
||||||
import { NeonButton, Input, GlassCard, StatsCard } from '@/components/ui'
|
import { NeonButton, Input, GlassCard, StatsCard } from '@/components/ui'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { useToast } from '@/store/toast'
|
import { useToast } from '@/store/toast'
|
||||||
import { useConfirm } from '@/store/confirm'
|
import { useConfirm } from '@/store/confirm'
|
||||||
|
import { fuzzyFilter } from '@/utils/fuzzySearch'
|
||||||
import {
|
import {
|
||||||
Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye,
|
Plus, Trash2, Sparkles, Play, Loader2, Gamepad2, X, Save, Eye,
|
||||||
ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft, Zap
|
ChevronDown, ChevronUp, Edit2, Check, CheckCircle, XCircle, Clock, User, ArrowLeft, Zap, Search, Settings
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { MarathonSettingsModal } from '@/components/MarathonSettingsModal'
|
||||||
|
|
||||||
export function LobbyPage() {
|
export function LobbyPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
@@ -27,9 +29,60 @@ export function LobbyPage() {
|
|||||||
const [showAddGame, setShowAddGame] = useState(false)
|
const [showAddGame, setShowAddGame] = useState(false)
|
||||||
const [gameTitle, setGameTitle] = useState('')
|
const [gameTitle, setGameTitle] = useState('')
|
||||||
const [gameUrl, setGameUrl] = useState('')
|
const [gameUrl, setGameUrl] = useState('')
|
||||||
|
const [gameUrlError, setGameUrlError] = useState<string | null>(null)
|
||||||
const [gameGenre, setGameGenre] = useState('')
|
const [gameGenre, setGameGenre] = useState('')
|
||||||
|
const [gameType, setGameType] = useState<GameType>('challenges')
|
||||||
|
const [playthroughPoints, setPlaythroughPoints] = useState(50)
|
||||||
|
const [playthroughDescription, setPlaythroughDescription] = useState('')
|
||||||
|
const [playthroughProofType, setPlaythroughProofType] = useState<ProofType>('screenshot')
|
||||||
|
const [playthroughProofHint, setPlaythroughProofHint] = useState('')
|
||||||
const [isAddingGame, setIsAddingGame] = useState(false)
|
const [isAddingGame, setIsAddingGame] = useState(false)
|
||||||
|
|
||||||
|
// Edit game modal
|
||||||
|
const [editingGame, setEditingGame] = useState<Game | null>(null)
|
||||||
|
const [editTitle, setEditTitle] = useState('')
|
||||||
|
const [editUrl, setEditUrl] = useState('')
|
||||||
|
const [editGenre, setEditGenre] = useState('')
|
||||||
|
const [editGameType, setEditGameType] = useState<GameType>('challenges')
|
||||||
|
const [editPlaythroughPoints, setEditPlaythroughPoints] = useState(50)
|
||||||
|
const [editPlaythroughDescription, setEditPlaythroughDescription] = useState('')
|
||||||
|
const [editPlaythroughProofType, setEditPlaythroughProofType] = useState<ProofType>('screenshot')
|
||||||
|
const [editPlaythroughProofHint, setEditPlaythroughProofHint] = useState('')
|
||||||
|
const [isEditingGame, setIsEditingGame] = useState(false)
|
||||||
|
|
||||||
|
const validateUrl = (url: string): boolean => {
|
||||||
|
if (!url.trim()) return true // Empty is ok, will be caught by required check
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url.trim())
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Check that hostname has at least one dot (domain.tld)
|
||||||
|
const hostname = parsed.hostname
|
||||||
|
if (!hostname || !hostname.includes('.')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Check that TLD is valid (2-6 letters only, like com, ru, org, online)
|
||||||
|
const parts = hostname.split('.')
|
||||||
|
const tld = parts[parts.length - 1].toLowerCase()
|
||||||
|
if (tld.length < 2 || tld.length > 6 || !/^[a-z]+$/.test(tld)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGameUrlChange = (value: string) => {
|
||||||
|
setGameUrl(value)
|
||||||
|
if (value.trim() && !validateUrl(value)) {
|
||||||
|
setGameUrlError('Введите корректную ссылку (например: https://store.steampowered.com/...)')
|
||||||
|
} else {
|
||||||
|
setGameUrlError(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Moderation
|
// Moderation
|
||||||
const [moderatingGameId, setModeratingGameId] = useState<number | null>(null)
|
const [moderatingGameId, setModeratingGameId] = useState<number | null>(null)
|
||||||
|
|
||||||
@@ -85,6 +138,21 @@ export function LobbyPage() {
|
|||||||
// Start marathon
|
// Start marathon
|
||||||
const [isStarting, setIsStarting] = useState(false)
|
const [isStarting, setIsStarting] = useState(false)
|
||||||
|
|
||||||
|
// Search
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [generateSearchQuery, setGenerateSearchQuery] = useState('')
|
||||||
|
|
||||||
|
// Games list filters
|
||||||
|
const [filterProposer, setFilterProposer] = useState<number | 'all'>('all')
|
||||||
|
const [filterChallenges, setFilterChallenges] = useState<'all' | 'with' | 'without'>('all')
|
||||||
|
|
||||||
|
// Generation filters
|
||||||
|
const [generateFilterProposer, setGenerateFilterProposer] = useState<number | 'all'>('all')
|
||||||
|
const [generateFilterChallenges, setGenerateFilterChallenges] = useState<'all' | 'with' | 'without'>('all')
|
||||||
|
|
||||||
|
// Settings modal
|
||||||
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
}, [id])
|
}, [id])
|
||||||
@@ -132,7 +200,15 @@ export function LobbyPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAddGame = async () => {
|
const handleAddGame = async () => {
|
||||||
if (!id || !gameTitle.trim() || !gameUrl.trim()) return
|
if (!id || !gameTitle.trim() || !gameUrl.trim() || !validateUrl(gameUrl)) return
|
||||||
|
|
||||||
|
// Validate playthrough fields
|
||||||
|
if (gameType === 'playthrough') {
|
||||||
|
if (!playthroughDescription.trim()) {
|
||||||
|
toast.warning('Заполните описание прохождения')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setIsAddingGame(true)
|
setIsAddingGame(true)
|
||||||
try {
|
try {
|
||||||
@@ -140,10 +216,24 @@ export function LobbyPage() {
|
|||||||
title: gameTitle.trim(),
|
title: gameTitle.trim(),
|
||||||
download_url: gameUrl.trim(),
|
download_url: gameUrl.trim(),
|
||||||
genre: gameGenre.trim() || undefined,
|
genre: gameGenre.trim() || undefined,
|
||||||
|
game_type: gameType,
|
||||||
|
...(gameType === 'playthrough' && {
|
||||||
|
playthrough_points: playthroughPoints,
|
||||||
|
playthrough_description: playthroughDescription.trim(),
|
||||||
|
playthrough_proof_type: playthroughProofType,
|
||||||
|
playthrough_proof_hint: playthroughProofHint.trim() || undefined,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
// Reset form
|
||||||
setGameTitle('')
|
setGameTitle('')
|
||||||
setGameUrl('')
|
setGameUrl('')
|
||||||
|
setGameUrlError(null)
|
||||||
setGameGenre('')
|
setGameGenre('')
|
||||||
|
setGameType('challenges')
|
||||||
|
setPlaythroughPoints(50)
|
||||||
|
setPlaythroughDescription('')
|
||||||
|
setPlaythroughProofType('screenshot')
|
||||||
|
setPlaythroughProofHint('')
|
||||||
setShowAddGame(false)
|
setShowAddGame(false)
|
||||||
await loadData()
|
await loadData()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -153,6 +243,56 @@ export function LobbyPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openEditModal = (game: Game) => {
|
||||||
|
setEditingGame(game)
|
||||||
|
setEditTitle(game.title)
|
||||||
|
setEditUrl(game.download_url)
|
||||||
|
setEditGenre(game.genre || '')
|
||||||
|
setEditGameType(game.game_type || 'challenges')
|
||||||
|
setEditPlaythroughPoints(game.playthrough_points || 50)
|
||||||
|
setEditPlaythroughDescription(game.playthrough_description || '')
|
||||||
|
setEditPlaythroughProofType((game.playthrough_proof_type as ProofType) || 'screenshot')
|
||||||
|
setEditPlaythroughProofHint(game.playthrough_proof_hint || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeEditModal = () => {
|
||||||
|
setEditingGame(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditGame = async () => {
|
||||||
|
if (!editingGame) return
|
||||||
|
|
||||||
|
// Validate playthrough fields
|
||||||
|
if (editGameType === 'playthrough' && !editPlaythroughDescription.trim()) {
|
||||||
|
toast.warning('Заполните описание прохождения')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsEditingGame(true)
|
||||||
|
try {
|
||||||
|
await gamesApi.update(editingGame.id, {
|
||||||
|
title: editTitle.trim(),
|
||||||
|
download_url: editUrl.trim(),
|
||||||
|
genre: editGenre.trim() || undefined,
|
||||||
|
game_type: editGameType,
|
||||||
|
...(editGameType === 'playthrough' && {
|
||||||
|
playthrough_points: editPlaythroughPoints,
|
||||||
|
playthrough_description: editPlaythroughDescription.trim(),
|
||||||
|
playthrough_proof_type: editPlaythroughProofType,
|
||||||
|
playthrough_proof_hint: editPlaythroughProofHint.trim() || undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
toast.success('Игра обновлена')
|
||||||
|
closeEditModal()
|
||||||
|
await loadData()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update game:', error)
|
||||||
|
toast.error('Не удалось обновить игру')
|
||||||
|
} finally {
|
||||||
|
setIsEditingGame(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleDeleteGame = async (gameId: number) => {
|
const handleDeleteGame = async (gameId: number) => {
|
||||||
const confirmed = await confirm({
|
const confirmed = await confirm({
|
||||||
title: 'Удалить игру?',
|
title: 'Удалить игру?',
|
||||||
@@ -501,6 +641,7 @@ export function LobbyPage() {
|
|||||||
} else {
|
} else {
|
||||||
setPreviewChallenges(result.challenges)
|
setPreviewChallenges(result.challenges)
|
||||||
setShowGenerateSelection(false)
|
setShowGenerateSelection(false)
|
||||||
|
setGenerateSearchQuery('')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to generate challenges:', error)
|
console.error('Failed to generate challenges:', error)
|
||||||
@@ -518,10 +659,6 @@ export function LobbyPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectAllGamesForGeneration = () => {
|
|
||||||
setSelectedGamesForGeneration(approvedGames.map(g => g.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearGameSelection = () => {
|
const clearGameSelection = () => {
|
||||||
setSelectedGamesForGeneration([])
|
setSelectedGamesForGeneration([])
|
||||||
}
|
}
|
||||||
@@ -599,6 +736,22 @@ export function LobbyPage() {
|
|||||||
const approvedGames = games.filter(g => g.status === 'approved')
|
const approvedGames = games.filter(g => g.status === 'approved')
|
||||||
const totalChallenges = approvedGames.reduce((sum, g) => sum + g.challenges_count, 0)
|
const totalChallenges = approvedGames.reduce((sum, g) => sum + g.challenges_count, 0)
|
||||||
|
|
||||||
|
// Get unique proposers for generation filter (from approved games)
|
||||||
|
const uniqueProposers = approvedGames.reduce((acc, game) => {
|
||||||
|
if (game.proposed_by && !acc.some(u => u.id === game.proposed_by?.id)) {
|
||||||
|
acc.push(game.proposed_by)
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, [] as { id: number; nickname: string }[])
|
||||||
|
|
||||||
|
// Get unique proposers for games list filter (from all games)
|
||||||
|
const allGamesProposers = games.reduce((acc, game) => {
|
||||||
|
if (game.proposed_by && !acc.some(u => u.id === game.proposed_by?.id)) {
|
||||||
|
acc.push(game.proposed_by)
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}, [] as { id: number; nickname: string }[])
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'approved':
|
case 'approved':
|
||||||
@@ -652,6 +805,11 @@ export function LobbyPage() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<h4 className="font-semibold text-white">{game.title}</h4>
|
<h4 className="font-semibold text-white">{game.title}</h4>
|
||||||
|
{game.game_type === 'playthrough' && (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-lg bg-accent-500/20 text-accent-400 border border-accent-500/30">
|
||||||
|
Прохождение
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{getStatusBadge(game.status)}
|
{getStatusBadge(game.status)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-400 flex items-center gap-3 flex-wrap">
|
<div className="text-sm text-gray-400 flex items-center gap-3 flex-wrap">
|
||||||
@@ -694,6 +852,15 @@ export function LobbyPage() {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{isOrganizer && (
|
||||||
|
<button
|
||||||
|
onClick={() => openEditModal(game)}
|
||||||
|
className="p-2 rounded-lg text-neon-400 hover:bg-neon-500/10 transition-colors"
|
||||||
|
title="Редактировать"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{(isOrganizer || game.proposed_by?.id === user?.id) && (
|
{(isOrganizer || game.proposed_by?.id === user?.id) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteGame(game.id)}
|
onClick={() => handleDeleteGame(game.id)}
|
||||||
@@ -782,7 +949,7 @@ export function LobbyPage() {
|
|||||||
value={editChallenge.points}
|
value={editChallenge.points}
|
||||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
|
||||||
min={1}
|
min={1}
|
||||||
max={500}
|
max={1000}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -798,6 +965,14 @@ export function LobbyPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Подсказка для пруфа</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Что именно должно быть на скриншоте/видео"
|
||||||
|
value={editChallenge.proof_hint}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, proof_hint: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<NeonButton
|
<NeonButton
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -846,6 +1021,9 @@ export function LobbyPage() {
|
|||||||
</div>
|
</div>
|
||||||
<h5 className="text-sm font-medium text-white">{challenge.title}</h5>
|
<h5 className="text-sm font-medium text-white">{challenge.title}</h5>
|
||||||
<p className="text-xs text-gray-400 mt-1">{challenge.description}</p>
|
<p className="text-xs text-gray-400 mt-1">{challenge.description}</p>
|
||||||
|
{challenge.proof_hint && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Пруф: {challenge.proof_hint}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isOrganizer && (
|
{isOrganizer && (
|
||||||
<div className="flex gap-1 shrink-0">
|
<div className="flex gap-1 shrink-0">
|
||||||
@@ -931,7 +1109,7 @@ export function LobbyPage() {
|
|||||||
value={newChallenge.points}
|
value={newChallenge.points}
|
||||||
onChange={(e) => setNewChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
|
onChange={(e) => setNewChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
|
||||||
min={1}
|
min={1}
|
||||||
max={500}
|
max={1000}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -1045,14 +1223,22 @@ export function LobbyPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isOrganizer && (
|
{isOrganizer && (
|
||||||
<NeonButton
|
<div className="flex gap-2">
|
||||||
onClick={handleStartMarathon}
|
<NeonButton
|
||||||
isLoading={isStarting}
|
variant="ghost"
|
||||||
disabled={approvedGames.length === 0}
|
onClick={() => setShowSettings(true)}
|
||||||
icon={<Play className="w-4 h-4" />}
|
className="!text-gray-400 hover:!bg-dark-600"
|
||||||
>
|
icon={<Settings className="w-4 h-4" />}
|
||||||
Запустить марафон
|
/>
|
||||||
</NeonButton>
|
<NeonButton
|
||||||
|
onClick={handleStartMarathon}
|
||||||
|
isLoading={isStarting}
|
||||||
|
disabled={approvedGames.length === 0}
|
||||||
|
icon={<Play className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Запустить марафон
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1165,7 +1351,7 @@ export function LobbyPage() {
|
|||||||
value={editChallenge.points}
|
value={editChallenge.points}
|
||||||
onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
|
||||||
min={1}
|
min={1}
|
||||||
max={500}
|
max={1000}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -1181,6 +1367,14 @@ export function LobbyPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Подсказка для пруфа</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Что именно должно быть на скриншоте/видео"
|
||||||
|
value={editChallenge.proof_hint}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, proof_hint: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<NeonButton
|
<NeonButton
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1226,6 +1420,9 @@ export function LobbyPage() {
|
|||||||
</div>
|
</div>
|
||||||
<h4 className="font-medium text-white mb-1">{challenge.title}</h4>
|
<h4 className="font-medium text-white mb-1">{challenge.title}</h4>
|
||||||
<p className="text-sm text-gray-400 mb-2">{challenge.description}</p>
|
<p className="text-sm text-gray-400 mb-2">{challenge.description}</p>
|
||||||
|
{challenge.proof_hint && (
|
||||||
|
<p className="text-xs text-gray-500 mb-2">Пруф: {challenge.proof_hint}</p>
|
||||||
|
)}
|
||||||
{challenge.proposed_by && (
|
{challenge.proposed_by && (
|
||||||
<p className="text-xs text-gray-500 flex items-center gap-1">
|
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
<User className="w-3 h-3" /> Предложил: {challenge.proposed_by.nickname}
|
<User className="w-3 h-3" /> Предложил: {challenge.proposed_by.nickname}
|
||||||
@@ -1304,6 +1501,9 @@ export function LobbyPage() {
|
|||||||
</div>
|
</div>
|
||||||
<h4 className="font-medium text-white mb-1">{challenge.title}</h4>
|
<h4 className="font-medium text-white mb-1">{challenge.title}</h4>
|
||||||
<p className="text-sm text-gray-400">{challenge.description}</p>
|
<p className="text-sm text-gray-400">{challenge.description}</p>
|
||||||
|
{challenge.proof_hint && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Пруф: {challenge.proof_hint}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{challenge.status === 'pending' && (
|
{challenge.status === 'pending' && (
|
||||||
<button
|
<button
|
||||||
@@ -1343,6 +1543,9 @@ export function LobbyPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowGenerateSelection(false)
|
setShowGenerateSelection(false)
|
||||||
clearGameSelection()
|
clearGameSelection()
|
||||||
|
setGenerateSearchQuery('')
|
||||||
|
setGenerateFilterProposer('all')
|
||||||
|
setGenerateFilterChallenges('all')
|
||||||
}}
|
}}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1376,51 +1579,135 @@ export function LobbyPage() {
|
|||||||
{/* Game selection */}
|
{/* Game selection */}
|
||||||
{showGenerateSelection && (
|
{showGenerateSelection && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between text-sm">
|
{/* Search */}
|
||||||
<button
|
<div className="relative">
|
||||||
onClick={selectAllGamesForGeneration}
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||||
className="text-neon-400 hover:text-neon-300 transition-colors"
|
<input
|
||||||
>
|
type="text"
|
||||||
Выбрать все
|
placeholder="Поиск игры..."
|
||||||
</button>
|
value={generateSearchQuery}
|
||||||
<button
|
onChange={(e) => setGenerateSearchQuery(e.target.value)}
|
||||||
onClick={clearGameSelection}
|
className="input w-full pl-10 pr-10 py-2 text-sm"
|
||||||
className="text-gray-400 hover:text-gray-300 transition-colors"
|
/>
|
||||||
>
|
{generateSearchQuery && (
|
||||||
Снять выбор
|
<button
|
||||||
</button>
|
onClick={() => setGenerateSearchQuery('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
{/* Filters */}
|
||||||
{approvedGames.map((game) => {
|
<div className="flex gap-2">
|
||||||
const isSelected = selectedGamesForGeneration.includes(game.id)
|
<select
|
||||||
const challengeCount = gameChallenges[game.id]?.length ?? game.challenges_count
|
value={generateFilterProposer === 'all' ? 'all' : generateFilterProposer}
|
||||||
return (
|
onChange={(e) => setGenerateFilterProposer(e.target.value === 'all' ? 'all' : parseInt(e.target.value))}
|
||||||
<button
|
className="input py-2 text-sm flex-1"
|
||||||
key={game.id}
|
>
|
||||||
onClick={() => toggleGameSelection(game.id)}
|
<option value="all">Все участники</option>
|
||||||
className={`flex items-center gap-3 p-3 rounded-lg border transition-all text-left ${
|
{uniqueProposers.map(u => (
|
||||||
isSelected
|
<option key={u.id} value={u.id}>{u.nickname}</option>
|
||||||
? 'bg-accent-500/20 border-accent-500/50'
|
))}
|
||||||
: 'bg-dark-700/50 border-dark-600 hover:border-dark-500'
|
</select>
|
||||||
}`}
|
<select
|
||||||
>
|
value={generateFilterChallenges}
|
||||||
<div className={`w-5 h-5 rounded flex items-center justify-center border-2 transition-colors ${
|
onChange={(e) => setGenerateFilterChallenges(e.target.value as 'all' | 'with' | 'without')}
|
||||||
isSelected
|
className="input py-2 text-sm flex-1"
|
||||||
? 'bg-accent-500 border-accent-500'
|
>
|
||||||
: 'border-gray-500'
|
<option value="all">Все игры</option>
|
||||||
}`}>
|
<option value="with">С заданиями</option>
|
||||||
{isSelected && <Check className="w-3 h-3 text-white" />}
|
<option value="without">Без заданий</option>
|
||||||
</div>
|
</select>
|
||||||
<div className="flex-1 min-w-0">
|
</div>
|
||||||
<p className="text-white font-medium truncate">{game.title}</p>
|
{(() => {
|
||||||
<p className="text-xs text-gray-400">
|
// Compute filtered games
|
||||||
{challengeCount > 0 ? `${challengeCount} заданий` : 'Нет заданий'}
|
let filteredGames = approvedGames
|
||||||
|
|
||||||
|
// Apply proposer filter
|
||||||
|
if (generateFilterProposer !== 'all') {
|
||||||
|
filteredGames = filteredGames.filter(g => g.proposed_by?.id === generateFilterProposer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply challenges filter
|
||||||
|
if (generateFilterChallenges === 'with') {
|
||||||
|
filteredGames = filteredGames.filter(g => {
|
||||||
|
const count = gameChallenges[g.id]?.length ?? g.challenges_count
|
||||||
|
return count > 0
|
||||||
|
})
|
||||||
|
} else if (generateFilterChallenges === 'without') {
|
||||||
|
filteredGames = filteredGames.filter(g => {
|
||||||
|
const count = gameChallenges[g.id]?.length ?? g.challenges_count
|
||||||
|
return count === 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if (generateSearchQuery) {
|
||||||
|
filteredGames = fuzzyFilter(filteredGames, generateSearchQuery, (g) => g.title + ' ' + (g.genre || ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedGamesForGeneration(filteredGames.map(g => g.id))}
|
||||||
|
className="text-neon-400 hover:text-neon-300 transition-colors"
|
||||||
|
>
|
||||||
|
Выбрать все ({filteredGames.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={clearGameSelection}
|
||||||
|
className="text-gray-400 hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Снять выбор
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 max-h-64 overflow-y-auto custom-scrollbar">
|
||||||
|
{filteredGames.length === 0 ? (
|
||||||
|
<p className="text-center text-gray-500 py-4 text-sm">
|
||||||
|
Ничего не найдено
|
||||||
</p>
|
</p>
|
||||||
</div>
|
) : (
|
||||||
</button>
|
filteredGames.map((game) => {
|
||||||
)
|
const isSelected = selectedGamesForGeneration.includes(game.id)
|
||||||
})}
|
const challengeCount = gameChallenges[game.id]?.length ?? game.challenges_count
|
||||||
</div>
|
return (
|
||||||
|
<button
|
||||||
|
key={game.id}
|
||||||
|
onClick={() => toggleGameSelection(game.id)}
|
||||||
|
className={`flex items-center gap-3 p-3 rounded-lg border transition-all text-left ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-accent-500/20 border-accent-500/50'
|
||||||
|
: 'bg-dark-700/50 border-dark-600 hover:border-dark-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`w-5 h-5 rounded flex items-center justify-center border-2 transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-accent-500 border-accent-500'
|
||||||
|
: 'border-gray-500'
|
||||||
|
}`}>
|
||||||
|
{isSelected && <Check className="w-3 h-3 text-white" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-white font-medium truncate">{game.title}</p>
|
||||||
|
{game.proposed_by && (
|
||||||
|
<span className="text-xs text-gray-500 shrink-0">от {game.proposed_by.nickname}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{challengeCount > 0 ? `${challengeCount} заданий` : 'Нет заданий'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1574,7 +1861,7 @@ export function LobbyPage() {
|
|||||||
|
|
||||||
{/* Games list */}
|
{/* Games list */}
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-xl bg-neon-500/20 flex items-center justify-center">
|
<div className="w-10 h-10 rounded-xl bg-neon-500/20 flex items-center justify-center">
|
||||||
<Gamepad2 className="w-5 h-5 text-neon-400" />
|
<Gamepad2 className="w-5 h-5 text-neon-400" />
|
||||||
@@ -1592,6 +1879,49 @@ export function LobbyPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search and filters */}
|
||||||
|
<div className="space-y-3 mb-6">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск игры..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="input w-full pl-10 pr-10"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={filterProposer === 'all' ? 'all' : filterProposer}
|
||||||
|
onChange={(e) => setFilterProposer(e.target.value === 'all' ? 'all' : parseInt(e.target.value))}
|
||||||
|
className="input py-2 text-sm flex-1"
|
||||||
|
>
|
||||||
|
<option value="all">Все участники</option>
|
||||||
|
{allGamesProposers.map(u => (
|
||||||
|
<option key={u.id} value={u.id}>{u.nickname}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={filterChallenges}
|
||||||
|
onChange={(e) => setFilterChallenges(e.target.value as 'all' | 'with' | 'without')}
|
||||||
|
className="input py-2 text-sm flex-1"
|
||||||
|
>
|
||||||
|
<option value="all">Все игры</option>
|
||||||
|
<option value="with">С заданиями</option>
|
||||||
|
<option value="without">Без заданий</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Add game form */}
|
{/* Add game form */}
|
||||||
{showAddGame && (
|
{showAddGame && (
|
||||||
<div className="mb-6 p-4 glass rounded-xl space-y-3 border border-neon-500/20">
|
<div className="mb-6 p-4 glass rounded-xl space-y-3 border border-neon-500/20">
|
||||||
@@ -1601,24 +1931,85 @@ export function LobbyPage() {
|
|||||||
onChange={(e) => setGameTitle(e.target.value)}
|
onChange={(e) => setGameTitle(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Ссылка для скачивания"
|
placeholder="Ссылка для скачивания (https://...)"
|
||||||
value={gameUrl}
|
value={gameUrl}
|
||||||
onChange={(e) => setGameUrl(e.target.value)}
|
onChange={(e) => handleGameUrlChange(e.target.value)}
|
||||||
|
error={gameUrlError || undefined}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Жанр (необязательно)"
|
placeholder="Жанр (необязательно)"
|
||||||
value={gameGenre}
|
value={gameGenre}
|
||||||
onChange={(e) => setGameGenre(e.target.value)}
|
onChange={(e) => setGameGenre(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Game type selector */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Тип игры</label>
|
||||||
|
<select
|
||||||
|
value={gameType}
|
||||||
|
onChange={(e) => setGameType(e.target.value as GameType)}
|
||||||
|
className="input w-full"
|
||||||
|
>
|
||||||
|
<option value="challenges">Челленджи — случайный челлендж при спине</option>
|
||||||
|
<option value="playthrough">Прохождение — основная задача + бонусные челленджи</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Playthrough fields */}
|
||||||
|
{gameType === 'playthrough' && (
|
||||||
|
<div className="space-y-3 p-3 bg-accent-500/10 rounded-lg border border-accent-500/20">
|
||||||
|
<p className="text-xs text-accent-400 font-medium">Настройки прохождения</p>
|
||||||
|
<textarea
|
||||||
|
placeholder="Что нужно сделать для прохождения (например: пройти игру до финальных титров)"
|
||||||
|
value={playthroughDescription}
|
||||||
|
onChange={(e) => setPlaythroughDescription(e.target.value)}
|
||||||
|
className="input w-full resize-none"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Очки за прохождение</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={playthroughPoints}
|
||||||
|
onChange={(e) => setPlaythroughPoints(parseInt(e.target.value) || 50)}
|
||||||
|
min={1}
|
||||||
|
max={1000}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Тип пруфа</label>
|
||||||
|
<select
|
||||||
|
value={playthroughProofType}
|
||||||
|
onChange={(e) => setPlaythroughProofType(e.target.value as ProofType)}
|
||||||
|
className="input w-full"
|
||||||
|
>
|
||||||
|
<option value="screenshot">Скриншот</option>
|
||||||
|
<option value="video">Видео</option>
|
||||||
|
<option value="steam">Steam</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="Подсказка для пруфа (необязательно)"
|
||||||
|
value={playthroughProofHint}
|
||||||
|
onChange={(e) => setPlaythroughProofHint(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Все челленджи этой игры станут бонусными (опциональными)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<NeonButton
|
<NeonButton
|
||||||
onClick={handleAddGame}
|
onClick={handleAddGame}
|
||||||
isLoading={isAddingGame}
|
isLoading={isAddingGame}
|
||||||
disabled={!gameTitle || !gameUrl}
|
disabled={!gameTitle || !gameUrl || !!gameUrlError || (gameType === 'playthrough' && !playthroughDescription)}
|
||||||
>
|
>
|
||||||
{isOrganizer ? 'Добавить' : 'Предложить'}
|
{isOrganizer ? 'Добавить' : 'Предложить'}
|
||||||
</NeonButton>
|
</NeonButton>
|
||||||
<NeonButton variant="outline" onClick={() => setShowAddGame(false)}>
|
<NeonButton variant="outline" onClick={() => { setShowAddGame(false); setGameUrlError(null); setGameType('challenges') }}>
|
||||||
Отмена
|
Отмена
|
||||||
</NeonButton>
|
</NeonButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -1632,26 +2023,177 @@ export function LobbyPage() {
|
|||||||
|
|
||||||
{/* Games */}
|
{/* Games */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const visibleGames = isOrganizer
|
let filteredGames = isOrganizer
|
||||||
? games.filter(g => g.status !== 'pending')
|
? games.filter(g => g.status !== 'pending')
|
||||||
: games.filter(g => g.status === 'approved' || (g.status === 'pending' && g.proposed_by?.id === user?.id))
|
: games.filter(g => g.status === 'approved' || (g.status === 'pending' && g.proposed_by?.id === user?.id))
|
||||||
|
|
||||||
return visibleGames.length === 0 ? (
|
// Apply proposer filter
|
||||||
|
if (filterProposer !== 'all') {
|
||||||
|
filteredGames = filteredGames.filter(g => g.proposed_by?.id === filterProposer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply challenges filter
|
||||||
|
if (filterChallenges === 'with') {
|
||||||
|
filteredGames = filteredGames.filter(g => {
|
||||||
|
const count = gameChallenges[g.id]?.length ?? g.challenges_count
|
||||||
|
return count > 0
|
||||||
|
})
|
||||||
|
} else if (filterChallenges === 'without') {
|
||||||
|
filteredGames = filteredGames.filter(g => {
|
||||||
|
const count = gameChallenges[g.id]?.length ?? g.challenges_count
|
||||||
|
return count === 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if (searchQuery) {
|
||||||
|
filteredGames = fuzzyFilter(filteredGames, searchQuery, (g) => g.title + ' ' + (g.genre || ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFilters = searchQuery || filterProposer !== 'all' || filterChallenges !== 'all'
|
||||||
|
|
||||||
|
return filteredGames.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-700 flex items-center justify-center">
|
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-700 flex items-center justify-center">
|
||||||
<Gamepad2 className="w-8 h-8 text-gray-600" />
|
{hasFilters ? (
|
||||||
|
<Search className="w-8 h-8 text-gray-600" />
|
||||||
|
) : (
|
||||||
|
<Gamepad2 className="w-8 h-8 text-gray-600" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-400">
|
<p className="text-gray-400">
|
||||||
{isOrganizer ? 'Пока нет игр. Добавьте игры, чтобы начать!' : 'Пока нет одобренных игр. Предложите свою!'}
|
{hasFilters
|
||||||
|
? 'Ничего не найдено по заданным фильтрам'
|
||||||
|
: isOrganizer
|
||||||
|
? 'Пока нет игр. Добавьте игры, чтобы начать!'
|
||||||
|
: 'Пока нет одобренных игр. Предложите свою!'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{visibleGames.map((game) => renderGameCard(game, false))}
|
{filteredGames.map((game) => renderGameCard(game, false))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|
||||||
|
{/* Settings Modal */}
|
||||||
|
{marathon && (
|
||||||
|
<MarathonSettingsModal
|
||||||
|
marathon={marathon}
|
||||||
|
isOpen={showSettings}
|
||||||
|
onClose={() => setShowSettings(false)}
|
||||||
|
onUpdate={setMarathon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Game Modal */}
|
||||||
|
{editingGame && (
|
||||||
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="glass rounded-2xl border border-neon-500/20 w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-xl font-bold text-white">Редактировать игру</h3>
|
||||||
|
<button
|
||||||
|
onClick={closeEditModal}
|
||||||
|
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Название игры"
|
||||||
|
value={editTitle}
|
||||||
|
onChange={(e) => setEditTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Ссылка для скачивания"
|
||||||
|
value={editUrl}
|
||||||
|
onChange={(e) => setEditUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Жанр (необязательно)"
|
||||||
|
value={editGenre}
|
||||||
|
onChange={(e) => setEditGenre(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Game type selector */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Тип игры</label>
|
||||||
|
<select
|
||||||
|
value={editGameType}
|
||||||
|
onChange={(e) => setEditGameType(e.target.value as GameType)}
|
||||||
|
className="input w-full"
|
||||||
|
>
|
||||||
|
<option value="challenges">Челленджи</option>
|
||||||
|
<option value="playthrough">Прохождение</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Playthrough fields */}
|
||||||
|
{editGameType === 'playthrough' && (
|
||||||
|
<div className="space-y-3 p-3 bg-accent-500/10 rounded-lg border border-accent-500/20">
|
||||||
|
<p className="text-xs text-accent-400 font-medium">Настройки прохождения</p>
|
||||||
|
<textarea
|
||||||
|
placeholder="Описание прохождения"
|
||||||
|
value={editPlaythroughDescription}
|
||||||
|
onChange={(e) => setEditPlaythroughDescription(e.target.value)}
|
||||||
|
className="input w-full resize-none"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Очки</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editPlaythroughPoints}
|
||||||
|
onChange={(e) => setEditPlaythroughPoints(parseInt(e.target.value) || 50)}
|
||||||
|
min={1}
|
||||||
|
max={1000}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Тип пруфа</label>
|
||||||
|
<select
|
||||||
|
value={editPlaythroughProofType}
|
||||||
|
onChange={(e) => setEditPlaythroughProofType(e.target.value as ProofType)}
|
||||||
|
className="input w-full"
|
||||||
|
>
|
||||||
|
<option value="screenshot">Скриншот</option>
|
||||||
|
<option value="video">Видео</option>
|
||||||
|
<option value="steam">Steam</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="Подсказка для пруфа (необязательно)"
|
||||||
|
value={editPlaythroughProofHint}
|
||||||
|
onChange={(e) => setEditPlaythroughProofHint(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<NeonButton
|
||||||
|
className="flex-1"
|
||||||
|
onClick={handleEditGame}
|
||||||
|
isLoading={isEditingGame}
|
||||||
|
disabled={!editTitle.trim() || !editUrl.trim() || (editGameType === 'playthrough' && !editPlaythroughDescription.trim())}
|
||||||
|
icon={<Save className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</NeonButton>
|
||||||
|
<NeonButton variant="outline" onClick={closeEditModal}>
|
||||||
|
Отмена
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ export function LoginPage() {
|
|||||||
|
|
||||||
navigate('/marathons')
|
navigate('/marathons')
|
||||||
} catch {
|
} catch {
|
||||||
setSubmitError(error || 'Ошибка входа')
|
// Error is already set in store by login function
|
||||||
|
// Ban case is handled separately via banInfo state
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||||
import { marathonsApi, eventsApi, challengesApi } from '@/api'
|
import { marathonsApi, eventsApi, challengesApi } from '@/api'
|
||||||
import type { Marathon, ActiveEvent, Challenge } from '@/types'
|
import type { Marathon, ActiveEvent, Challenge, MarathonDispute } from '@/types'
|
||||||
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
|
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { useToast } from '@/store/toast'
|
import { useToast } from '@/store/toast'
|
||||||
@@ -9,13 +9,16 @@ import { useConfirm } from '@/store/confirm'
|
|||||||
import { EventBanner } from '@/components/EventBanner'
|
import { EventBanner } from '@/components/EventBanner'
|
||||||
import { EventControl } from '@/components/EventControl'
|
import { EventControl } from '@/components/EventControl'
|
||||||
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
|
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
|
||||||
|
import { MarathonSettingsModal } from '@/components/MarathonSettingsModal'
|
||||||
import {
|
import {
|
||||||
Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2,
|
Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2,
|
||||||
Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag,
|
Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag,
|
||||||
Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2, Sparkles
|
Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2, Sparkles,
|
||||||
|
AlertTriangle, Clock, CheckCircle, XCircle, ThumbsUp, ThumbsDown, ExternalLink, User
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { ru } from 'date-fns/locale'
|
import { ru } from 'date-fns/locale'
|
||||||
|
import { TelegramBotBanner } from '@/components/TelegramBotBanner'
|
||||||
|
|
||||||
export function MarathonPage() {
|
export function MarathonPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
@@ -34,12 +37,26 @@ export function MarathonPage() {
|
|||||||
const [showEventControl, setShowEventControl] = useState(false)
|
const [showEventControl, setShowEventControl] = useState(false)
|
||||||
const [showChallenges, setShowChallenges] = useState(false)
|
const [showChallenges, setShowChallenges] = useState(false)
|
||||||
const [expandedGameId, setExpandedGameId] = useState<number | null>(null)
|
const [expandedGameId, setExpandedGameId] = useState<number | null>(null)
|
||||||
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
const activityFeedRef = useRef<ActivityFeedRef>(null)
|
const activityFeedRef = useRef<ActivityFeedRef>(null)
|
||||||
|
|
||||||
|
// Disputes for organizers
|
||||||
|
const [showDisputes, setShowDisputes] = useState(false)
|
||||||
|
const [disputes, setDisputes] = useState<MarathonDispute[]>([])
|
||||||
|
const [loadingDisputes, setLoadingDisputes] = useState(false)
|
||||||
|
const [disputeFilter, setDisputeFilter] = useState<'open' | 'all'>('open')
|
||||||
|
const [resolvingDisputeId, setResolvingDisputeId] = useState<number | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMarathon()
|
loadMarathon()
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showDisputes) {
|
||||||
|
loadDisputes()
|
||||||
|
}
|
||||||
|
}, [showDisputes, disputeFilter])
|
||||||
|
|
||||||
const loadMarathon = async () => {
|
const loadMarathon = async () => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
try {
|
try {
|
||||||
@@ -77,6 +94,57 @@ export function MarathonPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadDisputes = async () => {
|
||||||
|
if (!id) return
|
||||||
|
setLoadingDisputes(true)
|
||||||
|
try {
|
||||||
|
const data = await marathonsApi.listDisputes(parseInt(id), disputeFilter)
|
||||||
|
setDisputes(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load disputes:', error)
|
||||||
|
toast.error('Не удалось загрузить оспаривания')
|
||||||
|
} finally {
|
||||||
|
setLoadingDisputes(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResolveDispute = async (disputeId: number, isValid: boolean) => {
|
||||||
|
if (!id) return
|
||||||
|
setResolvingDisputeId(disputeId)
|
||||||
|
try {
|
||||||
|
await marathonsApi.resolveDispute(parseInt(id), disputeId, isValid)
|
||||||
|
toast.success(isValid ? 'Пруф подтверждён' : 'Пруф отклонён')
|
||||||
|
await loadDisputes()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to resolve dispute:', error)
|
||||||
|
toast.error('Не удалось разрешить диспут')
|
||||||
|
} finally {
|
||||||
|
setResolvingDisputeId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDisputeDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleString('ru-RU', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDisputeTimeRemaining = (expiresAt: string) => {
|
||||||
|
const now = new Date()
|
||||||
|
const expires = new Date(expiresAt)
|
||||||
|
const diff = expires.getTime() - now.getTime()
|
||||||
|
|
||||||
|
if (diff <= 0) return 'Истекло'
|
||||||
|
|
||||||
|
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||||
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
||||||
|
|
||||||
|
return `${hours}ч ${minutes}м`
|
||||||
|
}
|
||||||
|
|
||||||
const getInviteLink = () => {
|
const getInviteLink = () => {
|
||||||
if (!marathon) return ''
|
if (!marathon) return ''
|
||||||
return `${window.location.origin}/invite/${marathon.invite_code}`
|
return `${window.location.origin}/invite/${marathon.invite_code}`
|
||||||
@@ -189,8 +257,22 @@ export function MarathonPage() {
|
|||||||
{/* Hero Banner */}
|
{/* Hero Banner */}
|
||||||
<div className="relative rounded-2xl overflow-hidden mb-8">
|
<div className="relative rounded-2xl overflow-hidden mb-8">
|
||||||
{/* Background */}
|
{/* Background */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-neon-500/10 via-dark-800 to-accent-500/10" />
|
{marathon.cover_url ? (
|
||||||
<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]" />
|
<>
|
||||||
|
<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="relative p-8">
|
||||||
<div className="flex flex-col md:flex-row justify-between items-start gap-6">
|
<div className="flex flex-col md:flex-row justify-between items-start gap-6">
|
||||||
@@ -226,8 +308,8 @@ export function MarathonPage() {
|
|||||||
|
|
||||||
{marathon.status === 'preparing' && isOrganizer && (
|
{marathon.status === 'preparing' && isOrganizer && (
|
||||||
<Link to={`/marathons/${id}/lobby`}>
|
<Link to={`/marathons/${id}/lobby`}>
|
||||||
<NeonButton variant="secondary" icon={<Settings className="w-4 h-4" />}>
|
<NeonButton variant="secondary" icon={<Gamepad2 className="w-4 h-4" />}>
|
||||||
Настройка
|
Игры
|
||||||
</NeonButton>
|
</NeonButton>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
@@ -265,6 +347,15 @@ export function MarathonPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{marathon.status === 'preparing' && isOrganizer && (
|
||||||
|
<NeonButton
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowSettings(true)}
|
||||||
|
className="!text-gray-400 hover:!bg-dark-600"
|
||||||
|
icon={<Settings className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<NeonButton
|
<NeonButton
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -316,6 +407,9 @@ export function MarathonPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Telegram Bot Banner */}
|
||||||
|
<TelegramBotBanner />
|
||||||
|
|
||||||
{/* Active event banner */}
|
{/* Active event banner */}
|
||||||
{marathon.status === 'active' && activeEvent?.event && (
|
{marathon.status === 'active' && activeEvent?.event && (
|
||||||
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
|
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
|
||||||
@@ -356,6 +450,196 @@ export function MarathonPage() {
|
|||||||
</GlassCard>
|
</GlassCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Disputes management for organizers */}
|
||||||
|
{marathon.status === 'active' && isOrganizer && (
|
||||||
|
<GlassCard>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDisputes(!showDisputes)}
|
||||||
|
className="w-full flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-orange-500/20 flex items-center justify-center">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<h3 className="font-semibold text-white">Оспаривания</h3>
|
||||||
|
<p className="text-sm text-gray-400">Проверьте спорные выполнения</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{disputes.filter(d => d.status === 'open').length > 0 && (
|
||||||
|
<span className="px-2 py-1 bg-orange-500/20 text-orange-400 rounded text-xs font-medium">
|
||||||
|
{disputes.filter(d => d.status === 'open').length} открыто
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{showDisputes ? (
|
||||||
|
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{showDisputes && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-dark-600">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
disputeFilter === 'open'
|
||||||
|
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
|
||||||
|
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
|
||||||
|
}`}
|
||||||
|
onClick={() => setDisputeFilter('open')}
|
||||||
|
>
|
||||||
|
Открытые
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
disputeFilter === 'all'
|
||||||
|
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30'
|
||||||
|
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
|
||||||
|
}`}
|
||||||
|
onClick={() => setDisputeFilter('all')}
|
||||||
|
>
|
||||||
|
Все
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{loadingDisputes ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-accent-500" />
|
||||||
|
</div>
|
||||||
|
) : disputes.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-green-500/10 border border-green-500/30 flex items-center justify-center">
|
||||||
|
<CheckCircle className="w-6 h-6 text-green-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
|
{disputeFilter === 'open' ? 'Нет открытых оспариваний' : 'Нет оспариваний'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{disputes.map((dispute) => (
|
||||||
|
<div
|
||||||
|
key={dispute.id}
|
||||||
|
className={`p-4 bg-dark-700/50 rounded-xl border ${
|
||||||
|
dispute.status === 'open' ? 'border-orange-500/30' : 'border-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Challenge title */}
|
||||||
|
<h4 className="text-white font-medium truncate mb-1">
|
||||||
|
{dispute.challenge_title}
|
||||||
|
</h4>
|
||||||
|
{/* Participants */}
|
||||||
|
<div className="flex flex-wrap gap-3 text-xs text-gray-400 mb-2">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<User className="w-3 h-3" />
|
||||||
|
Автор: <span className="text-white">{dispute.participant_nickname}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
Оспорил: <span className="text-white">{dispute.raised_by_nickname}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Reason */}
|
||||||
|
<p className="text-sm text-gray-300 mb-2 line-clamp-2">
|
||||||
|
{dispute.reason}
|
||||||
|
</p>
|
||||||
|
{/* Votes & Time */}
|
||||||
|
<div className="flex items-center gap-3 text-xs">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="flex items-center gap-0.5 text-green-400">
|
||||||
|
<ThumbsUp className="w-3 h-3" />
|
||||||
|
<span>{dispute.votes_valid}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-600">/</span>
|
||||||
|
<div className="flex items-center gap-0.5 text-red-400">
|
||||||
|
<ThumbsDown className="w-3 h-3" />
|
||||||
|
<span>{dispute.votes_invalid}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-500">{formatDisputeDate(dispute.created_at)}</span>
|
||||||
|
{dispute.status === 'open' && (
|
||||||
|
<span className="text-orange-400 flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{getDisputeTimeRemaining(dispute.expires_at)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Status & Actions */}
|
||||||
|
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||||
|
{dispute.status === 'open' ? (
|
||||||
|
<span className="px-2 py-1 bg-yellow-500/20 text-yellow-400 rounded text-xs font-medium flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
Открыт
|
||||||
|
</span>
|
||||||
|
) : dispute.status === 'valid' ? (
|
||||||
|
<span className="px-2 py-1 bg-green-500/20 text-green-400 rounded text-xs font-medium flex items-center gap-1">
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
Валидно
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-1 bg-red-500/20 text-red-400 rounded text-xs font-medium flex items-center gap-1">
|
||||||
|
<XCircle className="w-3 h-3" />
|
||||||
|
Невалидно
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Link to assignment */}
|
||||||
|
{dispute.assignment_id && (
|
||||||
|
<Link
|
||||||
|
to={`/assignments/${dispute.assignment_id}`}
|
||||||
|
className="text-xs text-accent-400 hover:text-accent-300 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
Открыть
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resolution buttons */}
|
||||||
|
{dispute.status === 'open' && (
|
||||||
|
<div className="flex gap-1.5 mt-1">
|
||||||
|
<NeonButton
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-green-500/50 text-green-400 hover:bg-green-500/10 !px-2 !py-1 text-xs"
|
||||||
|
onClick={() => handleResolveDispute(dispute.id, true)}
|
||||||
|
isLoading={resolvingDisputeId === dispute.id}
|
||||||
|
disabled={resolvingDisputeId !== null}
|
||||||
|
icon={<CheckCircle className="w-3 h-3" />}
|
||||||
|
>
|
||||||
|
Валидно
|
||||||
|
</NeonButton>
|
||||||
|
<NeonButton
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-red-500/50 text-red-400 hover:bg-red-500/10 !px-2 !py-1 text-xs"
|
||||||
|
onClick={() => handleResolveDispute(dispute.id, false)}
|
||||||
|
isLoading={resolvingDisputeId === dispute.id}
|
||||||
|
disabled={resolvingDisputeId !== null}
|
||||||
|
icon={<XCircle className="w-3 h-3" />}
|
||||||
|
>
|
||||||
|
Невалидно
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Invite link */}
|
{/* Invite link */}
|
||||||
{marathon.status !== 'finished' && (
|
{marathon.status !== 'finished' && (
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
@@ -529,6 +813,14 @@ export function MarathonPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Settings Modal */}
|
||||||
|
<MarathonSettingsModal
|
||||||
|
marathon={marathon}
|
||||||
|
isOpen={showSettings}
|
||||||
|
onClose={() => setShowSettings(false)}
|
||||||
|
onUpdate={setMarathon}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { marathonsApi } from '@/api'
|
|||||||
import type { MarathonListItem } from '@/types'
|
import type { MarathonListItem } from '@/types'
|
||||||
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
|
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
|
||||||
import { Plus, Users, Calendar, Loader2, Trophy, Gamepad2, ChevronRight, Hash, Sparkles } from 'lucide-react'
|
import { Plus, Users, Calendar, Loader2, Trophy, Gamepad2, ChevronRight, Hash, Sparkles } from 'lucide-react'
|
||||||
|
import { TelegramBotBanner } from '@/components/TelegramBotBanner'
|
||||||
|
import { AnnouncementBanner } from '@/components/AnnouncementBanner'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { ru } from 'date-fns/locale'
|
import { ru } from 'date-fns/locale'
|
||||||
|
|
||||||
@@ -145,6 +147,16 @@ export function MarathonsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Announcement Banner */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<AnnouncementBanner />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Telegram Bot Banner */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<TelegramBotBanner />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Join marathon */}
|
{/* Join marathon */}
|
||||||
{showJoinSection && (
|
{showJoinSection && (
|
||||||
<GlassCard className="mb-8 animate-slide-in-down" variant="neon">
|
<GlassCard className="mb-8 animate-slide-in-down" variant="neon">
|
||||||
@@ -221,10 +233,20 @@ export function MarathonsPage() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* Icon */}
|
{/* Cover or Icon */}
|
||||||
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center border border-neon-500/20 group-hover:border-neon-500/40 transition-colors">
|
{marathon.cover_url ? (
|
||||||
<Gamepad2 className="w-7 h-7 text-neon-400" />
|
<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">
|
||||||
</div>
|
<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 */}
|
{/* Info */}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { Marathon, Assignment, Game, ActiveEvent, SwapCandidate, MySwapRequ
|
|||||||
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
|
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
|
||||||
import { SpinWheel } from '@/components/SpinWheel'
|
import { SpinWheel } from '@/components/SpinWheel'
|
||||||
import { EventBanner } from '@/components/EventBanner'
|
import { EventBanner } from '@/components/EventBanner'
|
||||||
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target } from 'lucide-react'
|
import { Loader2, Upload, X, Gamepad2, ArrowLeftRight, Check, XCircle, Clock, Send, Trophy, Users, ArrowLeft, AlertTriangle, Zap, Flame, Target, Download } from 'lucide-react'
|
||||||
import { useToast } from '@/store/toast'
|
import { useToast } from '@/store/toast'
|
||||||
import { useConfirm } from '@/store/confirm'
|
import { useConfirm } from '@/store/confirm'
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ export function PlayPage() {
|
|||||||
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
|
const [activeEvent, setActiveEvent] = useState<ActiveEvent | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
const [proofFile, setProofFile] = useState<File | null>(null)
|
const [proofFiles, setProofFiles] = useState<File[]>([])
|
||||||
const [proofUrl, setProofUrl] = useState('')
|
const [proofUrl, setProofUrl] = useState('')
|
||||||
const [comment, setComment] = useState('')
|
const [comment, setComment] = useState('')
|
||||||
const [isCompleting, setIsCompleting] = useState(false)
|
const [isCompleting, setIsCompleting] = useState(false)
|
||||||
@@ -55,6 +55,15 @@ export function PlayPage() {
|
|||||||
|
|
||||||
const [returnedAssignments, setReturnedAssignments] = useState<ReturnedAssignment[]>([])
|
const [returnedAssignments, setReturnedAssignments] = useState<ReturnedAssignment[]>([])
|
||||||
|
|
||||||
|
// Bonus challenge completion
|
||||||
|
const [expandedBonusId, setExpandedBonusId] = useState<number | null>(null)
|
||||||
|
const [bonusProofFiles, setBonusProofFiles] = useState<File[]>([])
|
||||||
|
const [bonusProofUrl, setBonusProofUrl] = useState('')
|
||||||
|
const [bonusComment, setBonusComment] = useState('')
|
||||||
|
const [isCompletingBonus, setIsCompletingBonus] = useState(false)
|
||||||
|
|
||||||
|
const bonusFileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const eventFileInputRef = useRef<HTMLInputElement>(null)
|
const eventFileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@@ -168,17 +177,17 @@ export function PlayPage() {
|
|||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
try {
|
try {
|
||||||
const [marathonData, assignment, gamesData, eventData, eventAssignmentData, returnedData] = await Promise.all([
|
const [marathonData, assignment, availableGamesData, eventData, eventAssignmentData, returnedData] = await Promise.all([
|
||||||
marathonsApi.get(parseInt(id)),
|
marathonsApi.get(parseInt(id)),
|
||||||
wheelApi.getCurrentAssignment(parseInt(id)),
|
wheelApi.getCurrentAssignment(parseInt(id)),
|
||||||
gamesApi.list(parseInt(id), 'approved'),
|
gamesApi.getAvailableGames(parseInt(id)),
|
||||||
eventsApi.getActive(parseInt(id)),
|
eventsApi.getActive(parseInt(id)),
|
||||||
eventsApi.getEventAssignment(parseInt(id)),
|
eventsApi.getEventAssignment(parseInt(id)),
|
||||||
assignmentsApi.getReturnedAssignments(parseInt(id)),
|
assignmentsApi.getReturnedAssignments(parseInt(id)),
|
||||||
])
|
])
|
||||||
setMarathon(marathonData)
|
setMarathon(marathonData)
|
||||||
setCurrentAssignment(assignment)
|
setCurrentAssignment(assignment)
|
||||||
setGames(gamesData)
|
setGames(availableGamesData)
|
||||||
setActiveEvent(eventData)
|
setActiveEvent(eventData)
|
||||||
setEventAssignment(eventAssignmentData)
|
setEventAssignment(eventAssignmentData)
|
||||||
setReturnedAssignments(returnedData)
|
setReturnedAssignments(returnedData)
|
||||||
@@ -219,20 +228,30 @@ export function PlayPage() {
|
|||||||
|
|
||||||
const handleComplete = async () => {
|
const handleComplete = async () => {
|
||||||
if (!currentAssignment) return
|
if (!currentAssignment) return
|
||||||
if (!proofFile && !proofUrl) {
|
|
||||||
toast.warning('Пожалуйста, предоставьте доказательство (файл или ссылку)')
|
// For playthrough: allow file, URL, or comment
|
||||||
return
|
// 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)
|
setIsCompleting(true)
|
||||||
try {
|
try {
|
||||||
const result = await wheelApi.complete(currentAssignment.id, {
|
const result = await wheelApi.complete(currentAssignment.id, {
|
||||||
proof_file: proofFile || undefined,
|
proof_files: proofFiles.length > 0 ? proofFiles : undefined,
|
||||||
proof_url: proofUrl || undefined,
|
proof_url: proofUrl || undefined,
|
||||||
comment: comment || undefined,
|
comment: comment || undefined,
|
||||||
})
|
})
|
||||||
toast.success(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`)
|
toast.success(`Выполнено! +${result.points_earned} очков (бонус серии: +${result.streak_bonus})`)
|
||||||
setProofFile(null)
|
setProofFiles([])
|
||||||
setProofUrl('')
|
setProofUrl('')
|
||||||
setComment('')
|
setComment('')
|
||||||
await loadData()
|
await loadData()
|
||||||
@@ -270,6 +289,39 @@ export function PlayPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleBonusComplete = async (bonusId: number) => {
|
||||||
|
if (!currentAssignment) return
|
||||||
|
if (bonusProofFiles.length === 0 && !bonusProofUrl && !bonusComment) {
|
||||||
|
toast.warning('Прикрепите файл, ссылку или комментарий')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCompletingBonus(true)
|
||||||
|
try {
|
||||||
|
const result = await assignmentsApi.completeBonusAssignment(
|
||||||
|
currentAssignment.id,
|
||||||
|
bonusId,
|
||||||
|
{
|
||||||
|
proof_files: bonusProofFiles.length > 0 ? bonusProofFiles : undefined,
|
||||||
|
proof_url: bonusProofUrl || undefined,
|
||||||
|
comment: bonusComment || undefined,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
toast.success(`Бонус отмечен! +${result.points_earned} очков начислится при завершении игры`)
|
||||||
|
setBonusProofFiles([])
|
||||||
|
setBonusProofUrl('')
|
||||||
|
setBonusComment('')
|
||||||
|
setExpandedBonusId(null)
|
||||||
|
if (bonusFileInputRef.current) bonusFileInputRef.current.value = ''
|
||||||
|
await loadData()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось выполнить бонус')
|
||||||
|
} finally {
|
||||||
|
setIsCompletingBonus(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleEventComplete = async () => {
|
const handleEventComplete = async () => {
|
||||||
if (!eventAssignment?.assignment) return
|
if (!eventAssignment?.assignment) return
|
||||||
if (!eventProofFile && !eventProofUrl) {
|
if (!eventProofFile && !eventProofUrl) {
|
||||||
@@ -529,12 +581,23 @@ export function PlayPage() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-white font-medium">{ra.challenge.title}</p>
|
{ra.is_playthrough ? (
|
||||||
<p className="text-gray-400 text-sm">{ra.challenge.game.title}</p>
|
<>
|
||||||
|
<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>
|
</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.is_playthrough && ra.challenge && (
|
||||||
+{ra.challenge.points}
|
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded-lg border border-green-500/30">
|
||||||
</span>
|
+{ra.challenge.points}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-orange-300 text-xs mt-2">
|
<p className="text-orange-300 text-xs mt-2">
|
||||||
Причина: {ra.dispute_reason}
|
Причина: {ra.dispute_reason}
|
||||||
@@ -640,28 +703,28 @@ export function PlayPage() {
|
|||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<p className="text-gray-400 text-sm mb-1">Игра</p>
|
<p className="text-gray-400 text-sm mb-1">Игра</p>
|
||||||
<p className="text-xl font-bold text-white">
|
<p className="text-xl font-bold text-white">
|
||||||
{eventAssignment.assignment.challenge.game.title}
|
{eventAssignment.assignment.challenge?.game.title}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<p className="text-gray-400 text-sm mb-1">Задание</p>
|
<p className="text-gray-400 text-sm mb-1">Задание</p>
|
||||||
<p className="text-xl font-bold text-neon-400 mb-2">
|
<p className="text-xl font-bold text-neon-400 mb-2">
|
||||||
{eventAssignment.assignment.challenge.title}
|
{eventAssignment.assignment.challenge?.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-300">
|
<p className="text-gray-300">
|
||||||
{eventAssignment.assignment.challenge.description}
|
{eventAssignment.assignment.challenge?.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-6 flex-wrap">
|
<div className="flex items-center gap-3 mb-6 flex-wrap">
|
||||||
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30">
|
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30">
|
||||||
+{eventAssignment.assignment.challenge.points} очков
|
+{eventAssignment.assignment.challenge?.points} очков
|
||||||
</span>
|
</span>
|
||||||
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
|
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
|
||||||
{eventAssignment.assignment.challenge.difficulty}
|
{eventAssignment.assignment.challenge?.difficulty}
|
||||||
</span>
|
</span>
|
||||||
{eventAssignment.assignment.challenge.estimated_time && (
|
{eventAssignment.assignment.challenge?.estimated_time && (
|
||||||
<span className="text-gray-400 text-sm flex items-center gap-1">
|
<span className="text-gray-400 text-sm flex items-center gap-1">
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
~{eventAssignment.assignment.challenge.estimated_time} мин
|
~{eventAssignment.assignment.challenge.estimated_time} мин
|
||||||
@@ -669,7 +732,7 @@ export function PlayPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{eventAssignment.assignment.challenge.proof_hint && (
|
{eventAssignment.assignment.challenge?.proof_hint && (
|
||||||
<div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
|
<div className="mb-6 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
<strong className="text-white">Нужно доказательство:</strong> {eventAssignment.assignment.challenge.proof_hint}
|
<strong className="text-white">Нужно доказательство:</strong> {eventAssignment.assignment.challenge.proof_hint}
|
||||||
@@ -680,7 +743,7 @@ export function PlayPage() {
|
|||||||
<div className="space-y-4 mb-6">
|
<div className="space-y-4 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
Загрузить доказательство ({eventAssignment.assignment.challenge.proof_type})
|
Загрузить доказательство ({eventAssignment.assignment.challenge?.proof_type})
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -891,74 +954,318 @@ export function PlayPage() {
|
|||||||
<>
|
<>
|
||||||
<GlassCard variant="neon">
|
<GlassCard variant="neon">
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<span className="px-4 py-1.5 bg-neon-500/20 text-neon-400 rounded-full text-sm font-medium border border-neon-500/30">
|
<span className={`px-4 py-1.5 rounded-full text-sm font-medium border ${
|
||||||
Активное задание
|
currentAssignment.is_playthrough
|
||||||
|
? 'bg-accent-500/20 text-accent-400 border-accent-500/30'
|
||||||
|
: 'bg-neon-500/20 text-neon-400 border-neon-500/30'
|
||||||
|
}`}>
|
||||||
|
{currentAssignment.is_playthrough ? 'Прохождение игры' : 'Активное задание'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<p className="text-gray-400 text-sm mb-1">Игра</p>
|
<p className="text-gray-400 text-sm mb-1">Игра</p>
|
||||||
<p className="text-xl font-bold text-white">
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
{currentAssignment.challenge.game.title}
|
<p className="text-xl font-bold text-white">
|
||||||
</p>
|
{currentAssignment.is_playthrough
|
||||||
</div>
|
? currentAssignment.game?.title
|
||||||
|
: currentAssignment.challenge?.game.title}
|
||||||
<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>
|
</p>
|
||||||
|
{(currentAssignment.is_playthrough
|
||||||
|
? currentAssignment.game?.download_url
|
||||||
|
: currentAssignment.challenge?.game.download_url) && (
|
||||||
|
<a
|
||||||
|
href={currentAssignment.is_playthrough
|
||||||
|
? currentAssignment.game?.download_url
|
||||||
|
: currentAssignment.challenge?.game.download_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="px-3 py-1.5 bg-neon-500/20 text-neon-400 rounded-lg text-sm font-medium border border-neon-500/30 flex items-center gap-1.5 hover:bg-neon-500/30 transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Скачать игру
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentAssignment.is_playthrough ? (
|
||||||
|
// Playthrough task
|
||||||
|
<>
|
||||||
|
<div className="mb-4">
|
||||||
|
<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 className="space-y-4 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
Загрузить доказательство ({currentAssignment.challenge.proof_type})
|
Загрузить доказательство ({currentAssignment.is_playthrough
|
||||||
|
? currentAssignment.playthrough_info?.proof_type
|
||||||
|
: currentAssignment.challenge?.proof_type})
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*,video/*"
|
accept="image/*,video/*"
|
||||||
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => validateAndSetFile(e.target.files?.[0] || null, setProofFile, fileInputRef)}
|
onChange={(e) => {
|
||||||
|
const files = Array.from(e.target.files || [])
|
||||||
|
setProofFiles(prev => [...prev, ...files])
|
||||||
|
// Reset input to allow selecting same files again
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{proofFile ? (
|
{proofFiles.length > 0 ? (
|
||||||
<div className="flex items-center gap-2 p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
<div className="space-y-2">
|
||||||
<span className="text-white flex-1 truncate">{proofFile.name}</span>
|
{proofFiles.map((file, index) => (
|
||||||
<button
|
<div key={index} className="flex items-center gap-2 p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||||
onClick={() => setProofFile(null)}
|
<span className="text-white flex-1 truncate">{file.name}</span>
|
||||||
className="p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
@@ -968,10 +1275,10 @@ export function PlayPage() {
|
|||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
icon={<Upload className="w-4 h-4" />}
|
icon={<Upload className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
Выбрать файл
|
Выбрать файлы
|
||||||
</NeonButton>
|
</NeonButton>
|
||||||
<p className="text-xs text-gray-500 mt-2 text-center">
|
<p className="text-xs text-gray-500 mt-2 text-center">
|
||||||
Макс. 15 МБ для изображений, 30 МБ для видео
|
Можно выбрать несколько файлов. Макс. 15 МБ для изображений, 30 МБ для видео
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1000,7 +1307,10 @@ export function PlayPage() {
|
|||||||
className="flex-1"
|
className="flex-1"
|
||||||
onClick={handleComplete}
|
onClick={handleComplete}
|
||||||
isLoading={isCompleting}
|
isLoading={isCompleting}
|
||||||
disabled={!proofFile && !proofUrl}
|
disabled={currentAssignment.is_playthrough
|
||||||
|
? (proofFiles.length === 0 && !proofUrl && !comment)
|
||||||
|
: (proofFiles.length === 0 && !proofUrl)
|
||||||
|
}
|
||||||
icon={<Check className="w-4 h-4" />}
|
icon={<Check className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
Выполнено
|
Выполнено
|
||||||
|
|||||||
107
frontend/src/pages/StaticContentPage.tsx
Normal file
107
frontend/src/pages/StaticContentPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@ import { adminApi } from '@/api'
|
|||||||
import type { StaticContent } from '@/types'
|
import type { StaticContent } from '@/types'
|
||||||
import { useToast } from '@/store/toast'
|
import { useToast } from '@/store/toast'
|
||||||
import { NeonButton } from '@/components/ui'
|
import { NeonButton } from '@/components/ui'
|
||||||
import { FileText, Plus, Pencil, X, Save, Code } from 'lucide-react'
|
import { FileText, Plus, Pencil, X, Save, Code, Trash2 } from 'lucide-react'
|
||||||
|
import { useConfirm } from '@/store/confirm'
|
||||||
|
|
||||||
function formatDate(dateStr: string) {
|
function formatDate(dateStr: string) {
|
||||||
return new Date(dateStr).toLocaleString('ru-RU', {
|
return new Date(dateStr).toLocaleString('ru-RU', {
|
||||||
@@ -28,6 +29,7 @@ export function AdminContentPage() {
|
|||||||
const [formContent, setFormContent] = useState('')
|
const [formContent, setFormContent] = useState('')
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadContents()
|
loadContents()
|
||||||
@@ -101,6 +103,30 @@ export function AdminContentPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@@ -155,15 +181,28 @@ export function AdminContentPage() {
|
|||||||
{content.content.replace(/<[^>]*>/g, '').slice(0, 100)}...
|
{content.content.replace(/<[^>]*>/g, '').slice(0, 100)}...
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-1 ml-3">
|
||||||
onClick={(e) => {
|
<button
|
||||||
e.stopPropagation()
|
onClick={(e) => {
|
||||||
handleEdit(content)
|
e.stopPropagation()
|
||||||
}}
|
handleEdit(content)
|
||||||
className="p-2 text-gray-400 hover:text-accent-400 hover:bg-accent-500/10 rounded-lg transition-colors ml-3"
|
}}
|
||||||
>
|
className="p-2 text-gray-400 hover:text-accent-400 hover:bg-accent-500/10 rounded-lg transition-colors"
|
||||||
<Pencil className="w-4 h-4" />
|
title="Редактировать"
|
||||||
</button>
|
>
|
||||||
|
<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>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-4 pt-3 border-t border-dark-600">
|
<p className="text-xs text-gray-500 mt-4 pt-3 border-t border-dark-600">
|
||||||
Обновлено: {formatDate(content.updated_at)}
|
Обновлено: {formatDate(content.updated_at)}
|
||||||
|
|||||||
312
frontend/src/pages/admin/AdminDisputesPage.tsx
Normal file
312
frontend/src/pages/admin/AdminDisputesPage.tsx
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { adminApi } from '@/api'
|
||||||
|
import type { AdminDispute } from '@/types'
|
||||||
|
import { GlassCard, NeonButton } from '@/components/ui'
|
||||||
|
import { useToast } from '@/store/toast'
|
||||||
|
import {
|
||||||
|
AlertTriangle, Loader2, CheckCircle, XCircle, Clock,
|
||||||
|
ThumbsUp, ThumbsDown, User, Trophy, ExternalLink
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
export function AdminDisputesPage() {
|
||||||
|
const toast = useToast()
|
||||||
|
const [disputes, setDisputes] = useState<AdminDispute[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [filter, setFilter] = useState<'pending' | 'open' | 'all'>('pending')
|
||||||
|
const [resolvingId, setResolvingId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDisputes()
|
||||||
|
}, [filter])
|
||||||
|
|
||||||
|
const loadDisputes = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.listDisputes(filter)
|
||||||
|
setDisputes(data)
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Не удалось загрузить оспаривания')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResolve = async (disputeId: number, isValid: boolean) => {
|
||||||
|
setResolvingId(disputeId)
|
||||||
|
try {
|
||||||
|
await adminApi.resolveDispute(disputeId, isValid)
|
||||||
|
toast.success(isValid ? 'Пруф подтверждён' : 'Пруф отклонён')
|
||||||
|
await loadDisputes()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Не удалось разрешить диспут')
|
||||||
|
} finally {
|
||||||
|
setResolvingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleString('ru-RU', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTimeRemaining = (expiresAt: string) => {
|
||||||
|
const now = new Date()
|
||||||
|
const expires = new Date(expiresAt)
|
||||||
|
const diff = expires.getTime() - now.getTime()
|
||||||
|
|
||||||
|
if (diff <= 0) return 'Истекло'
|
||||||
|
|
||||||
|
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||||
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
||||||
|
|
||||||
|
return `${hours}ч ${minutes}м`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'open':
|
||||||
|
return (
|
||||||
|
<span className="px-2 py-1 bg-blue-500/20 text-blue-400 rounded text-xs font-medium flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
Голосование
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
case 'pending_admin':
|
||||||
|
return (
|
||||||
|
<span className="px-2 py-1 bg-orange-500/20 text-orange-400 rounded text-xs font-medium flex items-center gap-1">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
Ожидает решения
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
case 'valid':
|
||||||
|
return (
|
||||||
|
<span className="px-2 py-1 bg-green-500/20 text-green-400 rounded text-xs font-medium flex items-center gap-1">
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
Валидно
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
case 'invalid':
|
||||||
|
return (
|
||||||
|
<span className="px-2 py-1 bg-red-500/20 text-red-400 rounded text-xs font-medium flex items-center gap-1">
|
||||||
|
<XCircle className="w-3 h-3" />
|
||||||
|
Невалидно
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingCount = disputes.filter(d => d.status === 'pending_admin').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 to-orange-400">
|
||||||
|
Оспаривания
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mt-1">
|
||||||
|
Управление диспутами и проверка пруфов
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<div className="px-4 py-2 bg-orange-500/20 border border-orange-500/30 rounded-xl">
|
||||||
|
<span className="text-orange-400 font-semibold">{pendingCount}</span>
|
||||||
|
<span className="text-gray-400 ml-2">ожида{pendingCount === 1 ? 'ет' : 'ют'} решения</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className={`px-4 py-2 rounded-lg font-medium transition-all ${
|
||||||
|
filter === 'pending'
|
||||||
|
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
|
||||||
|
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
|
||||||
|
}`}
|
||||||
|
onClick={() => setFilter('pending')}
|
||||||
|
>
|
||||||
|
Ожидают решения
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`px-4 py-2 rounded-lg font-medium transition-all ${
|
||||||
|
filter === 'open'
|
||||||
|
? 'bg-blue-500/20 text-blue-400 border border-blue-500/30'
|
||||||
|
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
|
||||||
|
}`}
|
||||||
|
onClick={() => setFilter('open')}
|
||||||
|
>
|
||||||
|
Голосование
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`px-4 py-2 rounded-lg font-medium transition-all ${
|
||||||
|
filter === 'all'
|
||||||
|
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30'
|
||||||
|
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
|
||||||
|
}`}
|
||||||
|
onClick={() => setFilter('all')}
|
||||||
|
>
|
||||||
|
Все
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-accent-500" />
|
||||||
|
</div>
|
||||||
|
) : disputes.length === 0 ? (
|
||||||
|
<GlassCard className="text-center py-12">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-green-500/10 border border-green-500/30 flex items-center justify-center">
|
||||||
|
<CheckCircle className="w-8 h-8 text-green-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
{filter === 'pending' ? 'Нет оспариваний, ожидающих решения' :
|
||||||
|
filter === 'open' ? 'Нет оспариваний в стадии голосования' :
|
||||||
|
'Нет оспариваний'}
|
||||||
|
</p>
|
||||||
|
</GlassCard>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{disputes.map((dispute) => (
|
||||||
|
<GlassCard
|
||||||
|
key={dispute.id}
|
||||||
|
className={
|
||||||
|
dispute.status === 'pending_admin' ? 'border-orange-500/30' :
|
||||||
|
dispute.status === 'open' ? 'border-blue-500/30' : ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
{/* Left side - Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center shrink-0">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-yellow-400" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-white font-semibold truncate">
|
||||||
|
{dispute.challenge_title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
|
<Trophy className="w-3 h-3" />
|
||||||
|
<span className="truncate">{dispute.marathon_title}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Participants */}
|
||||||
|
<div className="flex flex-wrap gap-4 mb-3 text-sm">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<User className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="text-gray-400">Автор:</span>
|
||||||
|
<span className="text-white">{dispute.participant_nickname}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-gray-500" />
|
||||||
|
<span className="text-gray-400">Оспорил:</span>
|
||||||
|
<span className="text-white">{dispute.raised_by_nickname}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reason */}
|
||||||
|
<div className="p-3 bg-dark-700/50 rounded-lg border border-dark-600 mb-3">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">Причина:</p>
|
||||||
|
<p className="text-white text-sm">{dispute.reason}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Votes & Time */}
|
||||||
|
<div className="flex items-center gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1 text-green-400">
|
||||||
|
<ThumbsUp className="w-4 h-4" />
|
||||||
|
<span className="font-medium">{dispute.votes_valid}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-600">/</span>
|
||||||
|
<div className="flex items-center gap-1 text-red-400">
|
||||||
|
<ThumbsDown className="w-4 h-4" />
|
||||||
|
<span className="font-medium">{dispute.votes_invalid}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-600">•</span>
|
||||||
|
<span className="text-gray-400">{formatDate(dispute.created_at)}</span>
|
||||||
|
{dispute.status === 'open' && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-600">•</span>
|
||||||
|
<span className="text-yellow-400 flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{getTimeRemaining(dispute.expires_at)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Status & Actions */}
|
||||||
|
<div className="flex flex-col items-end gap-3 shrink-0">
|
||||||
|
{getStatusBadge(dispute.status)}
|
||||||
|
|
||||||
|
{/* Link to assignment */}
|
||||||
|
{dispute.assignment_id && (
|
||||||
|
<Link
|
||||||
|
to={`/assignments/${dispute.assignment_id}`}
|
||||||
|
className="text-xs text-accent-400 hover:text-accent-300 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
Открыть
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resolution buttons - show for open and pending_admin */}
|
||||||
|
{(dispute.status === 'open' || dispute.status === 'pending_admin') && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{/* Vote recommendation for pending disputes */}
|
||||||
|
{dispute.status === 'pending_admin' && (
|
||||||
|
<div className="text-xs text-gray-400 text-right mb-1">
|
||||||
|
Рекомендация: {dispute.votes_invalid > dispute.votes_valid ? (
|
||||||
|
<span className="text-red-400">невалидно</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-green-400">валидно</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<NeonButton
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-green-500/50 text-green-400 hover:bg-green-500/10"
|
||||||
|
onClick={() => handleResolve(dispute.id, true)}
|
||||||
|
isLoading={resolvingId === dispute.id}
|
||||||
|
disabled={resolvingId !== null}
|
||||||
|
icon={<CheckCircle className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Валидно
|
||||||
|
</NeonButton>
|
||||||
|
<NeonButton
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-red-500/50 text-red-400 hover:bg-red-500/10"
|
||||||
|
onClick={() => handleResolve(dispute.id, false)}
|
||||||
|
isLoading={resolvingId === dispute.id}
|
||||||
|
disabled={resolvingId !== null}
|
||||||
|
icon={<XCircle className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Невалидно
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,13 +12,15 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Lock
|
Lock,
|
||||||
|
AlertTriangle
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/admin', icon: LayoutDashboard, label: 'Дашборд', end: true },
|
{ to: '/admin', icon: LayoutDashboard, label: 'Дашборд', end: true },
|
||||||
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
|
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
|
||||||
{ to: '/admin/marathons', icon: Trophy, label: 'Марафоны' },
|
{ to: '/admin/marathons', icon: Trophy, label: 'Марафоны' },
|
||||||
|
{ to: '/admin/disputes', icon: AlertTriangle, label: 'Оспаривания' },
|
||||||
{ to: '/admin/logs', icon: ScrollText, label: 'Логи' },
|
{ to: '/admin/logs', icon: ScrollText, label: 'Логи' },
|
||||||
{ to: '/admin/broadcast', icon: Send, label: 'Рассылка' },
|
{ to: '/admin/broadcast', icon: Send, label: 'Рассылка' },
|
||||||
{ to: '/admin/content', icon: FileText, label: 'Контент' },
|
{ to: '/admin/content', icon: FileText, label: 'Контент' },
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { AdminUser, UserRole } from '@/types'
|
|||||||
import { useToast } from '@/store/toast'
|
import { useToast } from '@/store/toast'
|
||||||
import { useConfirm } from '@/store/confirm'
|
import { useConfirm } from '@/store/confirm'
|
||||||
import { NeonButton } from '@/components/ui'
|
import { NeonButton } from '@/components/ui'
|
||||||
import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X } from 'lucide-react'
|
import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X, KeyRound } from 'lucide-react'
|
||||||
|
|
||||||
export function AdminUsersPage() {
|
export function AdminUsersPage() {
|
||||||
const [users, setUsers] = useState<AdminUser[]>([])
|
const [users, setUsers] = useState<AdminUser[]>([])
|
||||||
@@ -17,6 +17,9 @@ export function AdminUsersPage() {
|
|||||||
const [banDuration, setBanDuration] = useState<string>('permanent')
|
const [banDuration, setBanDuration] = useState<string>('permanent')
|
||||||
const [banCustomDate, setBanCustomDate] = useState('')
|
const [banCustomDate, setBanCustomDate] = useState('')
|
||||||
const [banning, setBanning] = useState(false)
|
const [banning, setBanning] = useState(false)
|
||||||
|
const [resetPasswordUser, setResetPasswordUser] = useState<AdminUser | null>(null)
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [resettingPassword, setResettingPassword] = useState(false)
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const confirm = useConfirm()
|
const confirm = useConfirm()
|
||||||
@@ -120,6 +123,24 @@ export function AdminUsersPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleResetPassword = async () => {
|
||||||
|
if (!resetPasswordUser || !newPassword.trim() || newPassword.length < 6) return
|
||||||
|
|
||||||
|
setResettingPassword(true)
|
||||||
|
try {
|
||||||
|
const updated = await adminApi.resetUserPassword(resetPasswordUser.id, newPassword)
|
||||||
|
setUsers(users.map(u => u.id === updated.id ? updated : u))
|
||||||
|
toast.success(`Пароль ${updated.nickname} сброшен`)
|
||||||
|
setResetPasswordUser(null)
|
||||||
|
setNewPassword('')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to reset password:', err)
|
||||||
|
toast.error('Ошибка сброса пароля')
|
||||||
|
} finally {
|
||||||
|
setResettingPassword(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -265,6 +286,14 @@ export function AdminUsersPage() {
|
|||||||
<Shield className="w-4 h-4" />
|
<Shield className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setResetPasswordUser(user)}
|
||||||
|
className="p-2 text-yellow-400 hover:bg-yellow-500/20 rounded-lg transition-colors"
|
||||||
|
title="Сбросить пароль"
|
||||||
|
>
|
||||||
|
<KeyRound className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -393,6 +422,71 @@ export function AdminUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Reset Password Modal */}
|
||||||
|
{resetPasswordUser && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
|
<div className="glass rounded-2xl p-6 max-w-md w-full mx-4 border border-dark-600 shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<KeyRound className="w-5 h-5 text-yellow-400" />
|
||||||
|
Сбросить пароль {resetPasswordUser.nickname}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setResetPasswordUser(null)
|
||||||
|
setNewPassword('')
|
||||||
|
}}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-white rounded-lg hover:bg-dark-600/50 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Новый пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="Минимум 6 символов"
|
||||||
|
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||||
|
/>
|
||||||
|
{newPassword && newPassword.length < 6 && (
|
||||||
|
<p className="mt-2 text-sm text-red-400">Пароль должен быть минимум 6 символов</p>
|
||||||
|
)}
|
||||||
|
{resetPasswordUser.telegram_id && (
|
||||||
|
<p className="mt-2 text-sm text-gray-400">
|
||||||
|
Пользователь получит уведомление в Telegram о смене пароля
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<NeonButton
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setResetPasswordUser(null)
|
||||||
|
setNewPassword('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</NeonButton>
|
||||||
|
<NeonButton
|
||||||
|
color="neon"
|
||||||
|
onClick={handleResetPassword}
|
||||||
|
disabled={!newPassword.trim() || newPassword.length < 6 || resettingPassword}
|
||||||
|
isLoading={resettingPassword}
|
||||||
|
icon={<KeyRound className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export { AdminLayout } from './AdminLayout'
|
|||||||
export { AdminDashboardPage } from './AdminDashboardPage'
|
export { AdminDashboardPage } from './AdminDashboardPage'
|
||||||
export { AdminUsersPage } from './AdminUsersPage'
|
export { AdminUsersPage } from './AdminUsersPage'
|
||||||
export { AdminMarathonsPage } from './AdminMarathonsPage'
|
export { AdminMarathonsPage } from './AdminMarathonsPage'
|
||||||
|
export { AdminDisputesPage } from './AdminDisputesPage'
|
||||||
export { AdminLogsPage } from './AdminLogsPage'
|
export { AdminLogsPage } from './AdminLogsPage'
|
||||||
export { AdminBroadcastPage } from './AdminBroadcastPage'
|
export { AdminBroadcastPage } from './AdminBroadcastPage'
|
||||||
export { AdminContentPage } from './AdminContentPage'
|
export { AdminContentPage } from './AdminContentPage'
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { persist } from 'zustand/middleware'
|
|||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
import { authApi, type RegisterData, type LoginData } from '@/api/auth'
|
import { authApi, type RegisterData, type LoginData } from '@/api/auth'
|
||||||
|
|
||||||
|
let syncPromise: Promise<void> | null = null
|
||||||
|
|
||||||
interface Pending2FA {
|
interface Pending2FA {
|
||||||
sessionId: number
|
sessionId: number
|
||||||
}
|
}
|
||||||
@@ -41,6 +43,7 @@ interface AuthState {
|
|||||||
bumpAvatarVersion: () => void
|
bumpAvatarVersion: () => void
|
||||||
setBanned: (banInfo: BanInfo) => void
|
setBanned: (banInfo: BanInfo) => void
|
||||||
clearBanned: () => void
|
clearBanned: () => void
|
||||||
|
syncUser: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
@@ -57,7 +60,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
banInfo: null,
|
banInfo: null,
|
||||||
|
|
||||||
login: async (data) => {
|
login: async (data) => {
|
||||||
set({ isLoading: true, error: null, pending2FA: null })
|
set({ isLoading: true, error: null, pending2FA: null, banInfo: null })
|
||||||
try {
|
try {
|
||||||
const response = await authApi.login(data)
|
const response = await authApi.login(data)
|
||||||
|
|
||||||
@@ -82,9 +85,34 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
return { requires2FA: false }
|
return { requires2FA: false }
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { status?: number; data?: { detail?: string | BanInfo } } }
|
||||||
|
|
||||||
|
// Check if user is banned (403 with ban info)
|
||||||
|
if (error.response?.status === 403) {
|
||||||
|
const detail = error.response?.data?.detail
|
||||||
|
if (typeof detail === 'object' && detail !== null && 'banned_at' in detail) {
|
||||||
|
set({
|
||||||
|
banInfo: detail as BanInfo,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular error - translate common messages
|
||||||
|
let errorMessage = 'Ошибка входа'
|
||||||
|
const detail = error.response?.data?.detail
|
||||||
|
if (typeof detail === 'string') {
|
||||||
|
if (detail === 'Incorrect login or password') {
|
||||||
|
errorMessage = 'Неверный логин или пароль'
|
||||||
|
} else {
|
||||||
|
errorMessage = detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
set({
|
set({
|
||||||
error: error.response?.data?.detail || 'Login failed',
|
error: errorMessage,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
})
|
})
|
||||||
throw err
|
throw err
|
||||||
@@ -145,6 +173,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
|
sessionStorage.removeItem('telegram_banner_dismissed')
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
token: null,
|
token: null,
|
||||||
@@ -181,6 +210,27 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
clearBanned: () => {
|
clearBanned: () => {
|
||||||
set({ banInfo: null })
|
set({ banInfo: null })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
syncUser: async () => {
|
||||||
|
if (!get().isAuthenticated || !get().token) return
|
||||||
|
|
||||||
|
// Prevent duplicate sync calls
|
||||||
|
if (syncPromise) return syncPromise
|
||||||
|
|
||||||
|
syncPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const userData = await authApi.me()
|
||||||
|
set({ user: userData })
|
||||||
|
} catch {
|
||||||
|
// Token invalid - logout
|
||||||
|
get().logout()
|
||||||
|
} finally {
|
||||||
|
syncPromise = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return syncPromise
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'auth-storage',
|
name: 'auth-storage',
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export interface Marathon {
|
|||||||
is_public: boolean
|
is_public: boolean
|
||||||
game_proposal_mode: GameProposalMode
|
game_proposal_mode: GameProposalMode
|
||||||
auto_events_enabled: boolean
|
auto_events_enabled: boolean
|
||||||
|
cover_url: string | null
|
||||||
start_date: string | null
|
start_date: string | null
|
||||||
end_date: string | null
|
end_date: string | null
|
||||||
participants_count: number
|
participants_count: number
|
||||||
@@ -76,6 +77,7 @@ export interface MarathonListItem {
|
|||||||
title: string
|
title: string
|
||||||
status: MarathonStatus
|
status: MarathonStatus
|
||||||
is_public: boolean
|
is_public: boolean
|
||||||
|
cover_url: string | null
|
||||||
participants_count: number
|
participants_count: number
|
||||||
start_date: string | null
|
start_date: string | null
|
||||||
end_date: string | null
|
end_date: string | null
|
||||||
@@ -90,11 +92,21 @@ export interface MarathonCreate {
|
|||||||
game_proposal_mode: GameProposalMode
|
game_proposal_mode: GameProposalMode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MarathonUpdate {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
start_date?: string
|
||||||
|
is_public?: boolean
|
||||||
|
game_proposal_mode?: GameProposalMode
|
||||||
|
auto_events_enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface MarathonPublicInfo {
|
export interface MarathonPublicInfo {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
description: string | null
|
description: string | null
|
||||||
status: MarathonStatus
|
status: MarathonStatus
|
||||||
|
cover_url: string | null
|
||||||
participants_count: number
|
participants_count: number
|
||||||
creator_nickname: string
|
creator_nickname: string
|
||||||
}
|
}
|
||||||
@@ -110,6 +122,14 @@ export interface LeaderboardEntry {
|
|||||||
|
|
||||||
// Game types
|
// Game types
|
||||||
export type GameStatus = 'pending' | 'approved' | 'rejected'
|
export type GameStatus = 'pending' | 'approved' | 'rejected'
|
||||||
|
export type GameType = 'challenges' | 'playthrough'
|
||||||
|
|
||||||
|
export interface PlaythroughInfo {
|
||||||
|
description: string
|
||||||
|
points: number
|
||||||
|
proof_type: ProofType
|
||||||
|
proof_hint: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface Game {
|
export interface Game {
|
||||||
id: number
|
id: number
|
||||||
@@ -122,12 +142,25 @@ export interface Game {
|
|||||||
approved_by: User | null
|
approved_by: User | null
|
||||||
challenges_count: number
|
challenges_count: number
|
||||||
created_at: string
|
created_at: string
|
||||||
|
// Game type fields
|
||||||
|
game_type: GameType
|
||||||
|
playthrough_points: number | null
|
||||||
|
playthrough_description: string | null
|
||||||
|
playthrough_proof_type: ProofType | null
|
||||||
|
playthrough_proof_hint: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameShort {
|
export interface GameShort {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
cover_url: string | null
|
cover_url: string | null
|
||||||
|
download_url?: string
|
||||||
|
game_type?: GameType
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailableGamesCount {
|
||||||
|
available: number
|
||||||
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Challenge types
|
// Challenge types
|
||||||
@@ -187,10 +220,28 @@ export interface ChallengesPreviewResponse {
|
|||||||
|
|
||||||
// Assignment types
|
// Assignment types
|
||||||
export type AssignmentStatus = 'active' | 'completed' | 'dropped' | 'returned'
|
export type AssignmentStatus = 'active' | 'completed' | 'dropped' | 'returned'
|
||||||
|
export type BonusAssignmentStatus = 'pending' | 'completed'
|
||||||
|
|
||||||
|
export interface BonusAssignment {
|
||||||
|
id: number
|
||||||
|
challenge: Challenge
|
||||||
|
status: BonusAssignmentStatus
|
||||||
|
proof_url: string | null
|
||||||
|
proof_image_url: string | null // Legacy, for backward compatibility
|
||||||
|
proof_files?: ProofFile[] // Multiple uploaded files
|
||||||
|
proof_comment: string | null
|
||||||
|
points_earned: number
|
||||||
|
completed_at: string | null
|
||||||
|
can_dispute?: boolean
|
||||||
|
dispute?: Dispute | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface Assignment {
|
export interface Assignment {
|
||||||
id: number
|
id: number
|
||||||
challenge: Challenge
|
challenge: Challenge | null // null for playthrough
|
||||||
|
game?: GameShort // For playthrough
|
||||||
|
is_playthrough?: boolean
|
||||||
|
playthrough_info?: PlaythroughInfo // For playthrough
|
||||||
status: AssignmentStatus
|
status: AssignmentStatus
|
||||||
proof_url: string | null
|
proof_url: string | null
|
||||||
proof_comment: string | null
|
proof_comment: string | null
|
||||||
@@ -199,12 +250,16 @@ export interface Assignment {
|
|||||||
started_at: string
|
started_at: string
|
||||||
completed_at: string | null
|
completed_at: string | null
|
||||||
drop_penalty: number
|
drop_penalty: number
|
||||||
|
bonus_challenges?: BonusAssignment[] // For playthrough
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpinResult {
|
export interface SpinResult {
|
||||||
assignment_id: number
|
assignment_id: number
|
||||||
game: Game
|
game: Game
|
||||||
challenge: Challenge
|
challenge: Challenge | null // null for playthrough
|
||||||
|
is_playthrough?: boolean
|
||||||
|
playthrough_info?: PlaythroughInfo // For playthrough
|
||||||
|
bonus_challenges?: Challenge[] // Available bonus challenges for playthrough
|
||||||
can_drop: boolean
|
can_drop: boolean
|
||||||
drop_penalty: number
|
drop_penalty: number
|
||||||
}
|
}
|
||||||
@@ -496,8 +551,42 @@ export interface DashboardStats {
|
|||||||
recent_logs: AdminLog[]
|
recent_logs: AdminLog[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin dispute
|
||||||
|
export interface AdminDispute {
|
||||||
|
id: number
|
||||||
|
assignment_id: number | null
|
||||||
|
bonus_assignment_id: number | null
|
||||||
|
marathon_id: number
|
||||||
|
marathon_title: string
|
||||||
|
challenge_title: string
|
||||||
|
participant_nickname: string
|
||||||
|
raised_by_nickname: string
|
||||||
|
reason: string
|
||||||
|
status: DisputeStatus
|
||||||
|
votes_valid: number
|
||||||
|
votes_invalid: number
|
||||||
|
created_at: string
|
||||||
|
expires_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marathon organizer dispute
|
||||||
|
export interface MarathonDispute {
|
||||||
|
id: number
|
||||||
|
assignment_id: number | null
|
||||||
|
bonus_assignment_id: number | null
|
||||||
|
challenge_title: string
|
||||||
|
participant_nickname: string
|
||||||
|
raised_by_nickname: string
|
||||||
|
reason: string
|
||||||
|
status: DisputeStatus
|
||||||
|
votes_valid: number
|
||||||
|
votes_invalid: number
|
||||||
|
created_at: string
|
||||||
|
expires_at: string
|
||||||
|
}
|
||||||
|
|
||||||
// Dispute types
|
// Dispute types
|
||||||
export type DisputeStatus = 'open' | 'valid' | 'invalid'
|
export type DisputeStatus = 'open' | 'pending_admin' | 'valid' | 'invalid'
|
||||||
|
|
||||||
export interface DisputeComment {
|
export interface DisputeComment {
|
||||||
id: number
|
id: number
|
||||||
@@ -527,13 +616,24 @@ export interface Dispute {
|
|||||||
resolved_at: string | null
|
resolved_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProofFile {
|
||||||
|
id: number
|
||||||
|
file_type: 'image' | 'video'
|
||||||
|
order_index: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface AssignmentDetail {
|
export interface AssignmentDetail {
|
||||||
id: number
|
id: number
|
||||||
challenge: Challenge
|
challenge: Challenge | null // null for playthrough
|
||||||
|
game?: GameShort // for playthrough
|
||||||
|
is_playthrough: boolean
|
||||||
|
playthrough_info?: PlaythroughInfo // for playthrough
|
||||||
participant: User
|
participant: User
|
||||||
status: AssignmentStatus
|
status: AssignmentStatus
|
||||||
proof_url: string | null
|
proof_url: string | null
|
||||||
proof_image_url: string | null
|
proof_image_url: string | null // Legacy, for backward compatibility
|
||||||
|
proof_files: ProofFile[] // Multiple uploaded files
|
||||||
proof_comment: string | null
|
proof_comment: string | null
|
||||||
points_earned: number
|
points_earned: number
|
||||||
streak_at_completion: number | null
|
streak_at_completion: number | null
|
||||||
@@ -541,11 +641,16 @@ export interface AssignmentDetail {
|
|||||||
completed_at: string | null
|
completed_at: string | null
|
||||||
can_dispute: boolean
|
can_dispute: boolean
|
||||||
dispute: Dispute | null
|
dispute: Dispute | null
|
||||||
|
bonus_challenges?: BonusAssignment[] // for playthrough
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReturnedAssignment {
|
export interface ReturnedAssignment {
|
||||||
id: number
|
id: number
|
||||||
challenge: Challenge
|
challenge: Challenge | null // For challenge assignments
|
||||||
|
is_playthrough: boolean
|
||||||
|
game_id: number | null // For playthrough assignments
|
||||||
|
game_title: string | null
|
||||||
|
game_cover_url: string | null
|
||||||
original_completed_at: string
|
original_completed_at: string
|
||||||
dispute_reason: string
|
dispute_reason: string
|
||||||
}
|
}
|
||||||
|
|||||||
123
frontend/src/utils/fuzzySearch.ts
Normal file
123
frontend/src/utils/fuzzySearch.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// Keyboard layout mapping (RU -> EN and EN -> RU)
|
||||||
|
const ruToEn: Record<string, string> = {
|
||||||
|
'й': 'q', 'ц': 'w', 'у': 'e', 'к': 'r', 'е': 't', 'н': 'y', 'г': 'u', 'ш': 'i', 'щ': 'o', 'з': 'p',
|
||||||
|
'ф': 'a', 'ы': 's', 'в': 'd', 'а': 'f', 'п': 'g', 'р': 'h', 'о': 'j', 'л': 'k', 'д': 'l',
|
||||||
|
'я': 'z', 'ч': 'x', 'с': 'c', 'м': 'v', 'и': 'b', 'т': 'n', 'ь': 'm',
|
||||||
|
'х': '[', 'ъ': ']', 'ж': ';', 'э': "'", 'б': ',', 'ю': '.',
|
||||||
|
}
|
||||||
|
|
||||||
|
const enToRu: Record<string, string> = Object.fromEntries(
|
||||||
|
Object.entries(ruToEn).map(([ru, en]) => [en, ru])
|
||||||
|
)
|
||||||
|
|
||||||
|
function convertLayout(text: string): string {
|
||||||
|
return text
|
||||||
|
.split('')
|
||||||
|
.map(char => {
|
||||||
|
const lower = char.toLowerCase()
|
||||||
|
const converted = ruToEn[lower] || enToRu[lower] || char
|
||||||
|
return char === lower ? converted : converted.toUpperCase()
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function levenshteinDistance(a: string, b: string): number {
|
||||||
|
const matrix: number[][] = []
|
||||||
|
|
||||||
|
for (let i = 0; i <= b.length; i++) {
|
||||||
|
matrix[i] = [i]
|
||||||
|
}
|
||||||
|
for (let j = 0; j <= a.length; j++) {
|
||||||
|
matrix[0][j] = j
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i <= b.length; i++) {
|
||||||
|
for (let j = 1; j <= a.length; j++) {
|
||||||
|
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
||||||
|
matrix[i][j] = matrix[i - 1][j - 1]
|
||||||
|
} else {
|
||||||
|
matrix[i][j] = Math.min(
|
||||||
|
matrix[i - 1][j - 1] + 1, // substitution
|
||||||
|
matrix[i][j - 1] + 1, // insertion
|
||||||
|
matrix[i - 1][j] + 1 // deletion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matrix[b.length][a.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FuzzyMatch<T> {
|
||||||
|
item: T
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fuzzySearch<T>(
|
||||||
|
items: T[],
|
||||||
|
query: string,
|
||||||
|
getSearchField: (item: T) => string
|
||||||
|
): FuzzyMatch<T>[] {
|
||||||
|
if (!query.trim()) {
|
||||||
|
return items.map(item => ({ item, score: 1 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedQuery = query.toLowerCase().trim()
|
||||||
|
const convertedQuery = convertLayout(normalizedQuery)
|
||||||
|
|
||||||
|
const results: FuzzyMatch<T>[] = []
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const field = getSearchField(item).toLowerCase()
|
||||||
|
|
||||||
|
// Exact substring match - highest score
|
||||||
|
if (field.includes(normalizedQuery)) {
|
||||||
|
results.push({ item, score: 1 })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converted layout match
|
||||||
|
if (field.includes(convertedQuery)) {
|
||||||
|
results.push({ item, score: 0.95 })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if query matches start of words
|
||||||
|
const words = field.split(/\s+/)
|
||||||
|
const queryWords = normalizedQuery.split(/\s+/)
|
||||||
|
const startsWithMatch = queryWords.every(qw =>
|
||||||
|
words.some(w => w.startsWith(qw))
|
||||||
|
)
|
||||||
|
if (startsWithMatch) {
|
||||||
|
results.push({ item, score: 0.9 })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Levenshtein distance for typo tolerance
|
||||||
|
const distance = levenshteinDistance(normalizedQuery, field)
|
||||||
|
const maxLen = Math.max(normalizedQuery.length, field.length)
|
||||||
|
const similarity = 1 - distance / maxLen
|
||||||
|
|
||||||
|
// Also check against converted query
|
||||||
|
const convertedDistance = levenshteinDistance(convertedQuery, field)
|
||||||
|
const convertedSimilarity = 1 - convertedDistance / maxLen
|
||||||
|
|
||||||
|
const bestSimilarity = Math.max(similarity, convertedSimilarity)
|
||||||
|
|
||||||
|
// Only include if similarity is reasonable (> 40%)
|
||||||
|
if (bestSimilarity > 0.4) {
|
||||||
|
results.push({ item, score: bestSimilarity * 0.8 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by score descending
|
||||||
|
return results.sort((a, b) => b.score - a.score)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fuzzyFilter<T>(
|
||||||
|
items: T[],
|
||||||
|
query: string,
|
||||||
|
getSearchField: (item: T) => string
|
||||||
|
): T[] {
|
||||||
|
return fuzzySearch(items, query, getSearchField).map(r => r.item)
|
||||||
|
}
|
||||||
20
nginx.conf
20
nginx.conf
@@ -17,10 +17,6 @@ http {
|
|||||||
# File upload limit (15 MB)
|
# File upload limit (15 MB)
|
||||||
client_max_body_size 15M;
|
client_max_body_size 15M;
|
||||||
|
|
||||||
# Rate limiting zones
|
|
||||||
limit_req_zone $binary_remote_addr zone=api_auth:10m rate=10r/m;
|
|
||||||
limit_req_zone $binary_remote_addr zone=api_general:10m rate=60r/m;
|
|
||||||
|
|
||||||
upstream backend {
|
upstream backend {
|
||||||
server backend:8000;
|
server backend:8000;
|
||||||
}
|
}
|
||||||
@@ -41,22 +37,8 @@ http {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Auth API - strict rate limit (10 req/min with burst of 5)
|
# Backend API (rate limiting handled by backend via RATE_LIMIT_ENABLED env)
|
||||||
location /api/v1/auth {
|
|
||||||
limit_req zone=api_auth burst=5 nodelay;
|
|
||||||
limit_req_status 429;
|
|
||||||
|
|
||||||
proxy_pass http://backend;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Backend API - general rate limit (60 req/min with burst of 20)
|
|
||||||
location /api {
|
location /api {
|
||||||
limit_req zone=api_general burst=20 nodelay;
|
|
||||||
limit_req_status 429;
|
|
||||||
|
|
||||||
proxy_pass http://backend;
|
proxy_pass http://backend;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
@@ -90,13 +90,14 @@ def get_latency_history(service_name: str, hours: int = 24) -> list[dict]:
|
|||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
since = datetime.now() - timedelta(hours=hours)
|
since = datetime.utcnow() - timedelta(hours=hours)
|
||||||
|
# Use strftime format to match SQLite CURRENT_TIMESTAMP format (no 'T')
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT latency_ms, status, checked_at
|
SELECT latency_ms, status, checked_at
|
||||||
FROM metrics
|
FROM metrics
|
||||||
WHERE service_name = ? AND checked_at > ? AND latency_ms IS NOT NULL
|
WHERE service_name = ? AND checked_at > ? AND latency_ms IS NOT NULL
|
||||||
ORDER BY checked_at ASC
|
ORDER BY checked_at ASC
|
||||||
""", (service_name, since.isoformat()))
|
""", (service_name, since.strftime("%Y-%m-%d %H:%M:%S")))
|
||||||
|
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -116,14 +117,14 @@ def get_uptime_stats(service_name: str, hours: int = 24) -> dict:
|
|||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
since = datetime.now() - timedelta(hours=hours)
|
since = datetime.utcnow() - timedelta(hours=hours)
|
||||||
|
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT COUNT(*) as total,
|
SELECT COUNT(*) as total,
|
||||||
SUM(CASE WHEN status = 'operational' THEN 1 ELSE 0 END) as successful
|
SUM(CASE WHEN status = 'operational' THEN 1 ELSE 0 END) as successful
|
||||||
FROM metrics
|
FROM metrics
|
||||||
WHERE service_name = ? AND checked_at > ?
|
WHERE service_name = ? AND checked_at > ?
|
||||||
""", (service_name, since.isoformat()))
|
""", (service_name, since.strftime("%Y-%m-%d %H:%M:%S")))
|
||||||
|
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -143,12 +144,12 @@ def get_avg_latency(service_name: str, hours: int = 24) -> Optional[float]:
|
|||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
since = datetime.now() - timedelta(hours=hours)
|
since = datetime.utcnow() - timedelta(hours=hours)
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT AVG(latency_ms) as avg_latency
|
SELECT AVG(latency_ms) as avg_latency
|
||||||
FROM metrics
|
FROM metrics
|
||||||
WHERE service_name = ? AND checked_at > ? AND latency_ms IS NOT NULL
|
WHERE service_name = ? AND checked_at > ? AND latency_ms IS NOT NULL
|
||||||
""", (service_name, since.isoformat()))
|
""", (service_name, since.strftime("%Y-%m-%d %H:%M:%S")))
|
||||||
|
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -231,7 +232,7 @@ def save_ssl_info(domain: str, issuer: str, expires_at: datetime, days_until_exp
|
|||||||
INSERT OR REPLACE INTO ssl_certificates
|
INSERT OR REPLACE INTO ssl_certificates
|
||||||
(domain, issuer, expires_at, days_until_expiry, checked_at)
|
(domain, issuer, expires_at, days_until_expiry, checked_at)
|
||||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
""", (domain, issuer, expires_at.isoformat(), days_until_expiry))
|
""", (domain, issuer, expires_at.strftime("%Y-%m-%d %H:%M:%S"), days_until_expiry))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -249,12 +250,12 @@ def get_ssl_info(domain: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def cleanup_old_metrics(days: int = 1):
|
def cleanup_old_metrics(hours: int = 24):
|
||||||
"""Delete metrics older than specified days (default: 24 hours)."""
|
"""Delete metrics older than specified hours (default: 24 hours)."""
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cutoff = datetime.now() - timedelta(days=days)
|
cutoff = datetime.utcnow() - timedelta(hours=hours)
|
||||||
cursor.execute("DELETE FROM metrics WHERE checked_at < ?", (cutoff.isoformat(),))
|
cursor.execute("DELETE FROM metrics WHERE checked_at < ?", (cutoff.strftime("%Y-%m-%d %H:%M:%S"),))
|
||||||
deleted = cursor.rowcount
|
deleted = cursor.rowcount
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from fastapi import FastAPI, Request
|
|||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from monitors import ServiceMonitor
|
from monitors import ServiceMonitor, Status
|
||||||
from database import init_db, get_recent_incidents, get_latency_history, cleanup_old_metrics
|
from database import init_db, get_recent_incidents, get_latency_history, cleanup_old_metrics
|
||||||
|
|
||||||
|
|
||||||
@@ -19,52 +19,91 @@ FRONTEND_URL = os.getenv("FRONTEND_URL", "http://frontend:80")
|
|||||||
BOT_URL = os.getenv("BOT_URL", "http://bot:8080")
|
BOT_URL = os.getenv("BOT_URL", "http://bot:8080")
|
||||||
EXTERNAL_URL = os.getenv("EXTERNAL_URL", "") # Public URL for external checks
|
EXTERNAL_URL = os.getenv("EXTERNAL_URL", "") # Public URL for external checks
|
||||||
PUBLIC_URL = os.getenv("PUBLIC_URL", "") # Public HTTPS URL for SSL checks
|
PUBLIC_URL = os.getenv("PUBLIC_URL", "") # Public HTTPS URL for SSL checks
|
||||||
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "600")) # 10 minutes
|
CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "60")) # Normal interval (1 minute)
|
||||||
|
FAST_CHECK_INTERVAL = int(os.getenv("FAST_CHECK_INTERVAL", "5")) # Fast interval when issues detected
|
||||||
|
STARTUP_GRACE_PERIOD = int(os.getenv("STARTUP_GRACE_PERIOD", "60")) # Wait before alerting after startup
|
||||||
|
|
||||||
# Initialize monitor
|
# Initialize monitor
|
||||||
monitor = ServiceMonitor()
|
monitor = ServiceMonitor()
|
||||||
|
startup_time: Optional[datetime] = None # Track when service started
|
||||||
|
|
||||||
# Background task reference
|
# Background task reference
|
||||||
background_task: Optional[asyncio.Task] = None
|
background_task: Optional[asyncio.Task] = None
|
||||||
cleanup_task: Optional[asyncio.Task] = None
|
cleanup_task: Optional[asyncio.Task] = None
|
||||||
|
|
||||||
|
|
||||||
|
def has_issues() -> bool:
|
||||||
|
"""Check if any monitored service has issues."""
|
||||||
|
for name, svc in monitor.services.items():
|
||||||
|
# Skip external if not configured
|
||||||
|
if name == "external" and svc.status == Status.UNKNOWN:
|
||||||
|
continue
|
||||||
|
if svc.status in (Status.DOWN, Status.DEGRADED):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def periodic_health_check():
|
async def periodic_health_check():
|
||||||
"""Background task to check services periodically."""
|
"""Background task to check services periodically with adaptive polling."""
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
# Suppress alerts during startup grace period
|
||||||
|
suppress_alerts = is_in_grace_period()
|
||||||
|
if suppress_alerts:
|
||||||
|
remaining = STARTUP_GRACE_PERIOD - (datetime.now() - startup_time).total_seconds()
|
||||||
|
print(f"Grace period: {remaining:.0f}s remaining (alerts suppressed)")
|
||||||
|
|
||||||
await monitor.check_all_services(
|
await monitor.check_all_services(
|
||||||
backend_url=BACKEND_URL,
|
backend_url=BACKEND_URL,
|
||||||
frontend_url=FRONTEND_URL,
|
frontend_url=FRONTEND_URL,
|
||||||
bot_url=BOT_URL,
|
bot_url=BOT_URL,
|
||||||
external_url=EXTERNAL_URL,
|
external_url=EXTERNAL_URL,
|
||||||
public_url=PUBLIC_URL
|
public_url=PUBLIC_URL,
|
||||||
|
suppress_alerts=suppress_alerts
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Health check error: {e}")
|
print(f"Health check error: {e}")
|
||||||
await asyncio.sleep(CHECK_INTERVAL)
|
|
||||||
|
# Adaptive polling: check more frequently when issues detected
|
||||||
|
if has_issues():
|
||||||
|
await asyncio.sleep(FAST_CHECK_INTERVAL)
|
||||||
|
else:
|
||||||
|
await asyncio.sleep(CHECK_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
async def periodic_cleanup():
|
async def periodic_cleanup():
|
||||||
"""Background task to cleanup old metrics (hourly)."""
|
"""Background task to cleanup old metrics (runs immediately, then hourly)."""
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(3600) # 1 hour
|
|
||||||
try:
|
try:
|
||||||
deleted = cleanup_old_metrics(days=1) # Keep only last 24 hours
|
deleted = cleanup_old_metrics(hours=24) # Keep only last 24 hours
|
||||||
print(f"Cleaned up {deleted} old metrics")
|
if deleted > 0:
|
||||||
|
print(f"Cleaned up {deleted} old metrics")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Cleanup error: {e}")
|
print(f"Cleanup error: {e}")
|
||||||
|
await asyncio.sleep(3600) # Wait 1 hour before next cleanup
|
||||||
|
|
||||||
|
|
||||||
|
def is_in_grace_period() -> bool:
|
||||||
|
"""Check if we're still in startup grace period."""
|
||||||
|
if startup_time is None:
|
||||||
|
return True
|
||||||
|
elapsed = (datetime.now() - startup_time).total_seconds()
|
||||||
|
return elapsed < STARTUP_GRACE_PERIOD
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Startup and shutdown events."""
|
"""Startup and shutdown events."""
|
||||||
global background_task, cleanup_task
|
global background_task, cleanup_task, startup_time
|
||||||
|
|
||||||
# Initialize database
|
# Initialize database
|
||||||
init_db()
|
init_db()
|
||||||
print("Database initialized")
|
print("Database initialized")
|
||||||
|
|
||||||
|
# Mark startup time
|
||||||
|
startup_time = datetime.now()
|
||||||
|
print(f"Startup grace period: {STARTUP_GRACE_PERIOD}s (no alerts until services stabilize)")
|
||||||
|
|
||||||
# Start background health checks
|
# Start background health checks
|
||||||
background_task = asyncio.create_task(periodic_health_check())
|
background_task = asyncio.create_task(periodic_health_check())
|
||||||
cleanup_task = asyncio.create_task(periodic_cleanup())
|
cleanup_task = asyncio.create_task(periodic_cleanup())
|
||||||
@@ -91,12 +130,20 @@ templates = Jinja2Templates(directory="templates")
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def status_page(request: Request):
|
async def status_page(request: Request, period: int = 24):
|
||||||
"""Main status page."""
|
"""Main status page."""
|
||||||
services = monitor.get_all_statuses()
|
# Validate period (1, 12, or 24 hours)
|
||||||
|
if period not in (1, 12, 24):
|
||||||
|
period = 24
|
||||||
|
|
||||||
|
services = monitor.get_all_statuses(period_hours=period)
|
||||||
overall_status = monitor.get_overall_status()
|
overall_status = monitor.get_overall_status()
|
||||||
ssl_status = monitor.get_ssl_status()
|
ssl_status = monitor.get_ssl_status()
|
||||||
incidents = get_recent_incidents(limit=5)
|
incidents = get_recent_incidents(limit=5)
|
||||||
|
fast_mode = has_issues()
|
||||||
|
current_interval = FAST_CHECK_INTERVAL if fast_mode else CHECK_INTERVAL
|
||||||
|
grace_period_active = is_in_grace_period()
|
||||||
|
grace_period_remaining = max(0, STARTUP_GRACE_PERIOD - (datetime.now() - startup_time).total_seconds()) if startup_time else 0
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"index.html",
|
"index.html",
|
||||||
@@ -107,7 +154,11 @@ async def status_page(request: Request):
|
|||||||
"ssl_status": ssl_status,
|
"ssl_status": ssl_status,
|
||||||
"incidents": incidents,
|
"incidents": incidents,
|
||||||
"last_check": monitor.last_check,
|
"last_check": monitor.last_check,
|
||||||
"check_interval": CHECK_INTERVAL
|
"check_interval": current_interval,
|
||||||
|
"fast_mode": fast_mode,
|
||||||
|
"grace_period_active": grace_period_active,
|
||||||
|
"grace_period_remaining": int(grace_period_remaining),
|
||||||
|
"period": period
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -118,13 +169,15 @@ async def api_status():
|
|||||||
services = monitor.get_all_statuses()
|
services = monitor.get_all_statuses()
|
||||||
overall_status = monitor.get_overall_status()
|
overall_status = monitor.get_overall_status()
|
||||||
ssl_status = monitor.get_ssl_status()
|
ssl_status = monitor.get_ssl_status()
|
||||||
|
current_interval = FAST_CHECK_INTERVAL if has_issues() else CHECK_INTERVAL
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"overall_status": overall_status.value,
|
"overall_status": overall_status.value,
|
||||||
"services": {name: status.to_dict() for name, status in services.items()},
|
"services": {name: status.to_dict() for name, status in services.items()},
|
||||||
"ssl": ssl_status,
|
"ssl": ssl_status,
|
||||||
"last_check": monitor.last_check.isoformat() if monitor.last_check else None,
|
"last_check": monitor.last_check.isoformat() if monitor.last_check else None,
|
||||||
"check_interval_seconds": CHECK_INTERVAL
|
"check_interval_seconds": current_interval,
|
||||||
|
"fast_mode": has_issues()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -184,7 +184,8 @@ class ServiceMonitor:
|
|||||||
self,
|
self,
|
||||||
service_name: str,
|
service_name: str,
|
||||||
result: tuple,
|
result: tuple,
|
||||||
now: datetime
|
now: datetime,
|
||||||
|
suppress_alerts: bool = False
|
||||||
):
|
):
|
||||||
"""Process check result with DB persistence and alerting."""
|
"""Process check result with DB persistence and alerting."""
|
||||||
if isinstance(result, Exception):
|
if isinstance(result, Exception):
|
||||||
@@ -221,13 +222,14 @@ class ServiceMonitor:
|
|||||||
if stats["total_checks"] > 0:
|
if stats["total_checks"] > 0:
|
||||||
svc.uptime_percent = stats["uptime_percent"]
|
svc.uptime_percent = stats["uptime_percent"]
|
||||||
|
|
||||||
# Handle incident tracking and alerting
|
# Handle incident tracking and alerting (skip alerts during grace period)
|
||||||
if is_down and not was_down:
|
if is_down and not was_down:
|
||||||
# Service just went down
|
# Service just went down
|
||||||
svc.last_incident = now
|
svc.last_incident = now
|
||||||
incident_id = create_incident(service_name, status.value, message)
|
incident_id = create_incident(service_name, status.value, message)
|
||||||
await alert_service_down(service_name, svc.display_name, message)
|
if not suppress_alerts:
|
||||||
mark_incident_notified(incident_id)
|
await alert_service_down(service_name, svc.display_name, message)
|
||||||
|
mark_incident_notified(incident_id)
|
||||||
|
|
||||||
elif not is_down and was_down:
|
elif not is_down and was_down:
|
||||||
# Service recovered
|
# Service recovered
|
||||||
@@ -236,7 +238,8 @@ class ServiceMonitor:
|
|||||||
started_at = datetime.fromisoformat(open_incident["started_at"])
|
started_at = datetime.fromisoformat(open_incident["started_at"])
|
||||||
downtime_minutes = int((now - started_at).total_seconds() / 60)
|
downtime_minutes = int((now - started_at).total_seconds() / 60)
|
||||||
resolve_incident(service_name)
|
resolve_incident(service_name)
|
||||||
await alert_service_recovered(service_name, svc.display_name, downtime_minutes)
|
if not suppress_alerts:
|
||||||
|
await alert_service_recovered(service_name, svc.display_name, downtime_minutes)
|
||||||
|
|
||||||
async def check_all_services(
|
async def check_all_services(
|
||||||
self,
|
self,
|
||||||
@@ -244,7 +247,8 @@ class ServiceMonitor:
|
|||||||
frontend_url: str,
|
frontend_url: str,
|
||||||
bot_url: str,
|
bot_url: str,
|
||||||
external_url: str = "",
|
external_url: str = "",
|
||||||
public_url: str = ""
|
public_url: str = "",
|
||||||
|
suppress_alerts: bool = False
|
||||||
):
|
):
|
||||||
"""Check all services concurrently."""
|
"""Check all services concurrently."""
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
@@ -262,7 +266,7 @@ class ServiceMonitor:
|
|||||||
# Process results
|
# Process results
|
||||||
service_names = ["backend", "database", "frontend", "bot", "external"]
|
service_names = ["backend", "database", "frontend", "bot", "external"]
|
||||||
for i, service_name in enumerate(service_names):
|
for i, service_name in enumerate(service_names):
|
||||||
await self._process_check_result(service_name, results[i], now)
|
await self._process_check_result(service_name, results[i], now, suppress_alerts)
|
||||||
|
|
||||||
# Check SSL certificate (if public URL is HTTPS)
|
# Check SSL certificate (if public URL is HTTPS)
|
||||||
if public_url and public_url.startswith("https://"):
|
if public_url and public_url.startswith("https://"):
|
||||||
@@ -270,7 +274,15 @@ class ServiceMonitor:
|
|||||||
|
|
||||||
self.last_check = now
|
self.last_check = now
|
||||||
|
|
||||||
def get_all_statuses(self) -> dict[str, ServiceStatus]:
|
def get_all_statuses(self, period_hours: int = 24) -> dict[str, ServiceStatus]:
|
||||||
|
"""Get all service statuses with data for specified period."""
|
||||||
|
# Update historical data for requested period
|
||||||
|
for name, svc in self.services.items():
|
||||||
|
svc.latency_history = get_latency_history(name, hours=period_hours)
|
||||||
|
svc.avg_latency_24h = get_avg_latency(name, hours=period_hours)
|
||||||
|
stats = get_uptime_stats(name, hours=period_hours)
|
||||||
|
if stats["total_checks"] > 0:
|
||||||
|
svc.uptime_percent = stats["uptime_percent"]
|
||||||
return self.services
|
return self.services
|
||||||
|
|
||||||
def get_overall_status(self) -> Status:
|
def get_overall_status(self) -> Status:
|
||||||
|
|||||||
@@ -107,6 +107,32 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fast-mode-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: rgba(250, 204, 21, 0.15);
|
||||||
|
border: 1px solid rgba(250, 204, 21, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #facc15;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grace-period-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #3b82f6;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.services-grid {
|
.services-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
@@ -347,6 +373,37 @@
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.period-selector {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: rgba(30, 41, 59, 0.5);
|
||||||
|
border: 1px solid rgba(100, 116, 139, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-btn:hover {
|
||||||
|
border-color: rgba(0, 212, 255, 0.3);
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-btn.active {
|
||||||
|
background: linear-gradient(135deg, rgba(0, 212, 255, 0.2), rgba(168, 85, 247, 0.2));
|
||||||
|
border-color: rgba(0, 212, 255, 0.5);
|
||||||
|
color: #00d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
.refresh-btn {
|
.refresh-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -424,9 +481,21 @@
|
|||||||
Checking services...
|
Checking services...
|
||||||
{% endif %}
|
{% endif %}
|
||||||
• Auto-refresh every {{ check_interval }}s
|
• Auto-refresh every {{ check_interval }}s
|
||||||
|
{% if grace_period_active %}
|
||||||
|
<span class="grace-period-badge">🚀 Startup ({{ grace_period_remaining }}s)</span>
|
||||||
|
{% elif fast_mode %}
|
||||||
|
<span class="fast-mode-badge">⚡ Fast Mode</span>
|
||||||
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- Period Selector -->
|
||||||
|
<div class="period-selector">
|
||||||
|
<a href="?period=1" class="period-btn {% if period == 1 %}active{% endif %}">1 час</a>
|
||||||
|
<a href="?period=12" class="period-btn {% if period == 12 %}active{% endif %}">12 часов</a>
|
||||||
|
<a href="?period=24" class="period-btn {% if period == 24 %}active{% endif %}">24 часа</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if ssl_status %}
|
{% if ssl_status %}
|
||||||
<div class="ssl-card {% if ssl_status.days_until_expiry <= 0 %}danger{% elif ssl_status.days_until_expiry <= 14 %}warning{% endif %}">
|
<div class="ssl-card {% if ssl_status.days_until_expiry <= 0 %}danger{% elif ssl_status.days_until_expiry <= 14 %}warning{% endif %}">
|
||||||
<div class="ssl-header">
|
<div class="ssl-header">
|
||||||
@@ -491,7 +560,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<div class="metric-label">Avg 24h</div>
|
<div class="metric-label">Avg {{ period }}h</div>
|
||||||
<div class="metric-value {% if service.avg_latency_24h and service.avg_latency_24h < 200 %}good{% elif service.avg_latency_24h and service.avg_latency_24h < 500 %}warning{% elif service.avg_latency_24h %}bad{% endif %}">
|
<div class="metric-value {% if service.avg_latency_24h and service.avg_latency_24h < 200 %}good{% elif service.avg_latency_24h and service.avg_latency_24h < 500 %}warning{% elif service.avg_latency_24h %}bad{% endif %}">
|
||||||
{% if service.avg_latency_24h %}
|
{% if service.avg_latency_24h %}
|
||||||
{{ "%.0f"|format(service.avg_latency_24h) }} ms
|
{{ "%.0f"|format(service.avg_latency_24h) }} ms
|
||||||
@@ -501,7 +570,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric">
|
<div class="metric">
|
||||||
<div class="metric-label">Uptime 24h</div>
|
<div class="metric-label">Uptime {{ period }}h</div>
|
||||||
<div class="metric-value {% if service.uptime_percent >= 99 %}good{% elif service.uptime_percent >= 95 %}warning{% else %}bad{% endif %}">
|
<div class="metric-value {% if service.uptime_percent >= 99 %}good{% elif service.uptime_percent >= 95 %}warning{% else %}bad{% endif %}">
|
||||||
{{ "%.1f"|format(service.uptime_percent) }}%
|
{{ "%.1f"|format(service.uptime_percent) }}%
|
||||||
</div>
|
</div>
|
||||||
@@ -620,13 +689,29 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
// Save scroll position before unload
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
sessionStorage.setItem('scrollPos', window.scrollY.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore scroll position on load
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
const scrollPos = sessionStorage.getItem('scrollPos');
|
||||||
|
if (scrollPos) {
|
||||||
|
window.scrollTo(0, parseInt(scrollPos));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function refreshStatus(btn) {
|
async function refreshStatus(btn) {
|
||||||
btn.classList.add('loading');
|
btn.classList.add('loading');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch('/api/refresh', { method: 'POST' });
|
await fetch('/api/refresh', { method: 'POST' });
|
||||||
window.location.reload();
|
// Preserve period parameter on reload
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.set('period', '{{ period }}');
|
||||||
|
window.location.href = url.toString();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Refresh failed:', e);
|
console.error('Refresh failed:', e);
|
||||||
btn.classList.remove('loading');
|
btn.classList.remove('loading');
|
||||||
@@ -634,9 +719,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-refresh page
|
// Auto-refresh page (preserve period parameter)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.reload();
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.set('period', '{{ period }}');
|
||||||
|
window.location.href = url.toString();
|
||||||
}, {{ check_interval }} * 1000);
|
}, {{ check_interval }} * 1000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user