Compare commits
10 Commits
33f49f4e47
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 243abe55b5 | |||
| c645171671 | |||
| 07745ea4ed | |||
| 22385e8742 | |||
| a77a757317 | |||
| 2d281d1c8c | |||
| 13f484e726 | |||
| ebaf6d39ea | |||
| 481bdabaa8 | |||
| 8e634994bd |
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
|
||||||
|
if not column_exists('users', 'role'):
|
||||||
op.add_column('users', sa.Column('role', sa.String(20), nullable=False, server_default='user'))
|
op.add_column('users', sa.Column('role', sa.String(20), nullable=False, server_default='user'))
|
||||||
|
|
||||||
# Add role column to participants table
|
# Add role column to participants table
|
||||||
|
if not column_exists('participants', 'role'):
|
||||||
op.add_column('participants', sa.Column('role', sa.String(20), nullable=False, server_default='participant'))
|
op.add_column('participants', sa.Column('role', sa.String(20), nullable=False, server_default='participant'))
|
||||||
|
|
||||||
# Rename organizer_id to creator_id in marathons table
|
# Rename organizer_id to creator_id in marathons table
|
||||||
|
if column_exists('marathons', 'organizer_id') and not column_exists('marathons', 'creator_id'):
|
||||||
op.alter_column('marathons', 'organizer_id', new_column_name='creator_id')
|
op.alter_column('marathons', 'organizer_id', new_column_name='creator_id')
|
||||||
|
|
||||||
# Update existing participants: set role='organizer' for marathon creators
|
# Update existing participants: set role='organizer' for marathon creators
|
||||||
|
# This is idempotent - running multiple times is safe
|
||||||
op.execute("""
|
op.execute("""
|
||||||
UPDATE participants p
|
UPDATE participants p
|
||||||
SET role = 'organizer'
|
SET role = 'organizer'
|
||||||
@@ -36,13 +55,17 @@ def upgrade() -> None:
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
# Add status column to games table
|
# Add status column to games table
|
||||||
|
if not column_exists('games', 'status'):
|
||||||
op.add_column('games', sa.Column('status', sa.String(20), nullable=False, server_default='approved'))
|
op.add_column('games', sa.Column('status', sa.String(20), nullable=False, server_default='approved'))
|
||||||
|
|
||||||
# Rename added_by_id to proposed_by_id in games table
|
# Rename added_by_id to proposed_by_id in games table
|
||||||
|
if column_exists('games', 'added_by_id') and not column_exists('games', 'proposed_by_id'):
|
||||||
op.alter_column('games', 'added_by_id', new_column_name='proposed_by_id')
|
op.alter_column('games', 'added_by_id', new_column_name='proposed_by_id')
|
||||||
|
|
||||||
# Add approved_by_id column to games table
|
# Add approved_by_id column to games table
|
||||||
|
if not column_exists('games', 'approved_by_id'):
|
||||||
op.add_column('games', sa.Column('approved_by_id', sa.Integer(), nullable=True))
|
op.add_column('games', sa.Column('approved_by_id', sa.Integer(), nullable=True))
|
||||||
|
if not constraint_exists('games', 'fk_games_approved_by_id'):
|
||||||
op.create_foreign_key(
|
op.create_foreign_key(
|
||||||
'fk_games_approved_by_id',
|
'fk_games_approved_by_id',
|
||||||
'games', 'users',
|
'games', 'users',
|
||||||
@@ -53,20 +76,27 @@ def upgrade() -> None:
|
|||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
# Remove approved_by_id from games
|
# Remove approved_by_id from games
|
||||||
|
if constraint_exists('games', 'fk_games_approved_by_id'):
|
||||||
op.drop_constraint('fk_games_approved_by_id', 'games', type_='foreignkey')
|
op.drop_constraint('fk_games_approved_by_id', 'games', type_='foreignkey')
|
||||||
|
if column_exists('games', 'approved_by_id'):
|
||||||
op.drop_column('games', 'approved_by_id')
|
op.drop_column('games', 'approved_by_id')
|
||||||
|
|
||||||
# Rename proposed_by_id back to added_by_id
|
# Rename proposed_by_id back to added_by_id
|
||||||
|
if column_exists('games', 'proposed_by_id'):
|
||||||
op.alter_column('games', 'proposed_by_id', new_column_name='added_by_id')
|
op.alter_column('games', 'proposed_by_id', new_column_name='added_by_id')
|
||||||
|
|
||||||
# Remove status from games
|
# Remove status from games
|
||||||
|
if column_exists('games', 'status'):
|
||||||
op.drop_column('games', 'status')
|
op.drop_column('games', 'status')
|
||||||
|
|
||||||
# Rename creator_id back to organizer_id
|
# Rename creator_id back to organizer_id
|
||||||
|
if column_exists('marathons', 'creator_id'):
|
||||||
op.alter_column('marathons', 'creator_id', new_column_name='organizer_id')
|
op.alter_column('marathons', 'creator_id', new_column_name='organizer_id')
|
||||||
|
|
||||||
# Remove role from participants
|
# Remove role from participants
|
||||||
|
if column_exists('participants', 'role'):
|
||||||
op.drop_column('participants', 'role')
|
op.drop_column('participants', 'role')
|
||||||
|
|
||||||
# Remove role from users
|
# Remove role from users
|
||||||
|
if column_exists('users', 'role'):
|
||||||
op.drop_column('users', 'role')
|
op.drop_column('users', 'role')
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Sequence, Union
|
|||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '002_marathon_settings'
|
revision: str = '002_marathon_settings'
|
||||||
@@ -17,16 +18,27 @@ branch_labels: Union[str, Sequence[str], None] = None
|
|||||||
depends_on: Union[str, Sequence[str], None] = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# Add is_public column to marathons table (default False = private)
|
# Add is_public column to marathons table (default False = private)
|
||||||
|
if not column_exists('marathons', 'is_public'):
|
||||||
op.add_column('marathons', sa.Column('is_public', sa.Boolean(), nullable=False, server_default='false'))
|
op.add_column('marathons', sa.Column('is_public', sa.Boolean(), nullable=False, server_default='false'))
|
||||||
|
|
||||||
# Add game_proposal_mode column to marathons table
|
# Add game_proposal_mode column to marathons table
|
||||||
# 'all_participants' - anyone can propose games (with moderation)
|
# 'all_participants' - anyone can propose games (with moderation)
|
||||||
# 'organizer_only' - only organizers can add games
|
# 'organizer_only' - only organizers can add games
|
||||||
|
if not column_exists('marathons', 'game_proposal_mode'):
|
||||||
op.add_column('marathons', sa.Column('game_proposal_mode', sa.String(20), nullable=False, server_default='all_participants'))
|
op.add_column('marathons', sa.Column('game_proposal_mode', sa.String(20), nullable=False, server_default='all_participants'))
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
if column_exists('marathons', 'game_proposal_mode'):
|
||||||
op.drop_column('marathons', 'game_proposal_mode')
|
op.drop_column('marathons', 'game_proposal_mode')
|
||||||
|
if column_exists('marathons', 'is_public'):
|
||||||
op.drop_column('marathons', 'is_public')
|
op.drop_column('marathons', 'is_public')
|
||||||
|
|||||||
@@ -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:
|
||||||
|
if not column_exists('users', 'telegram_first_name'):
|
||||||
op.add_column('users', sa.Column('telegram_first_name', sa.String(100), nullable=True))
|
op.add_column('users', sa.Column('telegram_first_name', sa.String(100), nullable=True))
|
||||||
|
if not column_exists('users', 'telegram_last_name'):
|
||||||
op.add_column('users', sa.Column('telegram_last_name', sa.String(100), nullable=True))
|
op.add_column('users', sa.Column('telegram_last_name', sa.String(100), nullable=True))
|
||||||
|
if not column_exists('users', 'telegram_avatar_url'):
|
||||||
op.add_column('users', sa.Column('telegram_avatar_url', sa.String(500), nullable=True))
|
op.add_column('users', sa.Column('telegram_avatar_url', sa.String(500), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
if column_exists('users', 'telegram_avatar_url'):
|
||||||
op.drop_column('users', 'telegram_avatar_url')
|
op.drop_column('users', 'telegram_avatar_url')
|
||||||
|
if column_exists('users', 'telegram_last_name'):
|
||||||
op.drop_column('users', 'telegram_last_name')
|
op.drop_column('users', 'telegram_last_name')
|
||||||
|
if column_exists('users', 'telegram_first_name'):
|
||||||
op.drop_column('users', 'telegram_first_name')
|
op.drop_column('users', 'telegram_first_name')
|
||||||
|
|||||||
40
backend/alembic/versions/011_add_challenge_proposals.py
Normal file
40
backend/alembic/versions/011_add_challenge_proposals.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""Add challenge proposals support
|
||||||
|
|
||||||
|
Revision ID: 011_add_challenge_proposals
|
||||||
|
Revises: 010_add_telegram_profile
|
||||||
|
Create Date: 2024-12-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '011_add_challenge_proposals'
|
||||||
|
down_revision: Union[str, None] = '010_add_telegram_profile'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not column_exists('challenges', 'proposed_by_id'):
|
||||||
|
op.add_column('challenges', sa.Column('proposed_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True))
|
||||||
|
if not column_exists('challenges', 'status'):
|
||||||
|
op.add_column('challenges', sa.Column('status', sa.String(20), server_default='approved', nullable=False))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if column_exists('challenges', 'status'):
|
||||||
|
op.drop_column('challenges', 'status')
|
||||||
|
if column_exists('challenges', 'proposed_by_id'):
|
||||||
|
op.drop_column('challenges', 'proposed_by_id')
|
||||||
48
backend/alembic/versions/012_add_user_banned.py
Normal file
48
backend/alembic/versions/012_add_user_banned.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""Add user banned fields
|
||||||
|
|
||||||
|
Revision ID: 012_add_user_banned
|
||||||
|
Revises: 011_add_challenge_proposals
|
||||||
|
Create Date: 2024-12-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '012_add_user_banned'
|
||||||
|
down_revision: Union[str, None] = '011_add_challenge_proposals'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not column_exists('users', 'is_banned'):
|
||||||
|
op.add_column('users', sa.Column('is_banned', sa.Boolean(), server_default='false', nullable=False))
|
||||||
|
if not column_exists('users', 'banned_at'):
|
||||||
|
op.add_column('users', sa.Column('banned_at', sa.DateTime(), nullable=True))
|
||||||
|
if not column_exists('users', 'banned_by_id'):
|
||||||
|
op.add_column('users', sa.Column('banned_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True))
|
||||||
|
if not column_exists('users', 'ban_reason'):
|
||||||
|
op.add_column('users', sa.Column('ban_reason', sa.String(500), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if column_exists('users', 'ban_reason'):
|
||||||
|
op.drop_column('users', 'ban_reason')
|
||||||
|
if column_exists('users', 'banned_by_id'):
|
||||||
|
op.drop_column('users', 'banned_by_id')
|
||||||
|
if column_exists('users', 'banned_at'):
|
||||||
|
op.drop_column('users', 'banned_at')
|
||||||
|
if column_exists('users', 'is_banned'):
|
||||||
|
op.drop_column('users', 'is_banned')
|
||||||
61
backend/alembic/versions/013_add_admin_logs.py
Normal file
61
backend/alembic/versions/013_add_admin_logs.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Add admin_logs table
|
||||||
|
|
||||||
|
Revision ID: 013_add_admin_logs
|
||||||
|
Revises: 012_add_user_banned
|
||||||
|
Create Date: 2024-12-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '013_add_admin_logs'
|
||||||
|
down_revision: Union[str, None] = '012_add_user_banned'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
indexes = inspector.get_indexes(table_name)
|
||||||
|
return any(idx['name'] == index_name for idx in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not table_exists('admin_logs'):
|
||||||
|
op.create_table(
|
||||||
|
'admin_logs',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('admin_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||||
|
sa.Column('action', sa.String(50), nullable=False),
|
||||||
|
sa.Column('target_type', sa.String(50), nullable=False),
|
||||||
|
sa.Column('target_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('details', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('ip_address', sa.String(50), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not index_exists('admin_logs', 'ix_admin_logs_admin_id'):
|
||||||
|
op.create_index('ix_admin_logs_admin_id', 'admin_logs', ['admin_id'])
|
||||||
|
if not index_exists('admin_logs', 'ix_admin_logs_action'):
|
||||||
|
op.create_index('ix_admin_logs_action', 'admin_logs', ['action'])
|
||||||
|
if not index_exists('admin_logs', 'ix_admin_logs_created_at'):
|
||||||
|
op.create_index('ix_admin_logs_created_at', 'admin_logs', ['created_at'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_admin_logs_created_at', 'admin_logs')
|
||||||
|
op.drop_index('ix_admin_logs_action', 'admin_logs')
|
||||||
|
op.drop_index('ix_admin_logs_admin_id', 'admin_logs')
|
||||||
|
op.drop_table('admin_logs')
|
||||||
57
backend/alembic/versions/014_add_admin_2fa.py
Normal file
57
backend/alembic/versions/014_add_admin_2fa.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Add admin_2fa_sessions table
|
||||||
|
|
||||||
|
Revision ID: 014_add_admin_2fa
|
||||||
|
Revises: 013_add_admin_logs
|
||||||
|
Create Date: 2024-12-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '014_add_admin_2fa'
|
||||||
|
down_revision: Union[str, None] = '013_add_admin_logs'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
indexes = inspector.get_indexes(table_name)
|
||||||
|
return any(idx['name'] == index_name for idx in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not table_exists('admin_2fa_sessions'):
|
||||||
|
op.create_table(
|
||||||
|
'admin_2fa_sessions',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||||
|
sa.Column('code', sa.String(6), nullable=False),
|
||||||
|
sa.Column('telegram_sent', sa.Boolean(), server_default='false', nullable=False),
|
||||||
|
sa.Column('is_verified', sa.Boolean(), server_default='false', nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not index_exists('admin_2fa_sessions', 'ix_admin_2fa_sessions_user_id'):
|
||||||
|
op.create_index('ix_admin_2fa_sessions_user_id', 'admin_2fa_sessions', ['user_id'])
|
||||||
|
if not index_exists('admin_2fa_sessions', 'ix_admin_2fa_sessions_expires_at'):
|
||||||
|
op.create_index('ix_admin_2fa_sessions_expires_at', 'admin_2fa_sessions', ['expires_at'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_admin_2fa_sessions_expires_at', 'admin_2fa_sessions')
|
||||||
|
op.drop_index('ix_admin_2fa_sessions_user_id', 'admin_2fa_sessions')
|
||||||
|
op.drop_table('admin_2fa_sessions')
|
||||||
54
backend/alembic/versions/015_add_static_content.py
Normal file
54
backend/alembic/versions/015_add_static_content.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Add static_content table
|
||||||
|
|
||||||
|
Revision ID: 015_add_static_content
|
||||||
|
Revises: 014_add_admin_2fa
|
||||||
|
Create Date: 2024-12-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '015_add_static_content'
|
||||||
|
down_revision: Union[str, None] = '014_add_admin_2fa'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
indexes = inspector.get_indexes(table_name)
|
||||||
|
return any(idx['name'] == index_name for idx in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not table_exists('static_content'):
|
||||||
|
op.create_table(
|
||||||
|
'static_content',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('key', sa.String(100), unique=True, nullable=False),
|
||||||
|
sa.Column('title', sa.String(200), nullable=False),
|
||||||
|
sa.Column('content', sa.Text(), nullable=False),
|
||||||
|
sa.Column('updated_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not index_exists('static_content', 'ix_static_content_key'):
|
||||||
|
op.create_index('ix_static_content_key', 'static_content', ['key'], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_static_content_key', 'static_content')
|
||||||
|
op.drop_table('static_content')
|
||||||
36
backend/alembic/versions/016_add_banned_until.py
Normal file
36
backend/alembic/versions/016_add_banned_until.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""Add banned_until field
|
||||||
|
|
||||||
|
Revision ID: 016_add_banned_until
|
||||||
|
Revises: 015_add_static_content
|
||||||
|
Create Date: 2024-12-19
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '016_add_banned_until'
|
||||||
|
down_revision: Union[str, None] = '015_add_static_content'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not column_exists('users', 'banned_until'):
|
||||||
|
op.add_column('users', sa.Column('banned_until', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if column_exists('users', 'banned_until'):
|
||||||
|
op.drop_column('users', 'banned_until')
|
||||||
47
backend/alembic/versions/017_admin_logs_nullable_admin_id.py
Normal file
47
backend/alembic/versions/017_admin_logs_nullable_admin_id.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Make admin_id nullable in admin_logs for system actions
|
||||||
|
|
||||||
|
Revision ID: 017_admin_logs_nullable_admin_id
|
||||||
|
Revises: 016_add_banned_until
|
||||||
|
Create Date: 2024-12-19
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '017_admin_logs_nullable_admin_id'
|
||||||
|
down_revision: Union[str, None] = '016_add_banned_until'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def is_column_nullable(table_name: str, column_name: str) -> bool:
|
||||||
|
"""Check if a column is nullable."""
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = inspector.get_columns(table_name)
|
||||||
|
for col in columns:
|
||||||
|
if col['name'] == column_name:
|
||||||
|
return col.get('nullable', True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Make admin_id nullable for system actions (like auto-unban)
|
||||||
|
# Only alter if currently not nullable
|
||||||
|
if not is_column_nullable('admin_logs', 'admin_id'):
|
||||||
|
op.alter_column('admin_logs', 'admin_id',
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Revert to not nullable (will fail if there are NULL values)
|
||||||
|
if is_column_nullable('admin_logs', 'admin_id'):
|
||||||
|
op.alter_column('admin_logs', 'admin_id',
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
nullable=False)
|
||||||
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)})")
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status, Header
|
from fastapi import Depends, HTTPException, status, Header
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
@@ -8,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import decode_access_token
|
from app.core.security import decode_access_token
|
||||||
from app.models import User, Participant, Marathon, UserRole, ParticipantRole
|
from app.models import User, Participant, Marathon, UserRole, ParticipantRole, AdminLog, AdminActionType
|
||||||
|
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
|
||||||
@@ -43,6 +44,50 @@ async def get_current_user(
|
|||||||
detail="User not found",
|
detail="User not found",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check if user is banned
|
||||||
|
if user.is_banned:
|
||||||
|
# Auto-unban if ban expired
|
||||||
|
if user.banned_until and datetime.utcnow() > user.banned_until:
|
||||||
|
# Save ban info for logging before clearing
|
||||||
|
old_ban_reason = user.ban_reason
|
||||||
|
old_banned_until = user.banned_until.isoformat() if user.banned_until else None
|
||||||
|
|
||||||
|
user.is_banned = False
|
||||||
|
user.banned_at = None
|
||||||
|
user.banned_until = None
|
||||||
|
user.banned_by_id = None
|
||||||
|
user.ban_reason = None
|
||||||
|
|
||||||
|
# Log system auto-unban action
|
||||||
|
log = AdminLog(
|
||||||
|
admin_id=None, # System action, no admin
|
||||||
|
action=AdminActionType.USER_AUTO_UNBAN.value,
|
||||||
|
target_type="user",
|
||||||
|
target_id=user.id,
|
||||||
|
details={
|
||||||
|
"nickname": user.nickname,
|
||||||
|
"reason": old_ban_reason,
|
||||||
|
"banned_until": old_banned_until,
|
||||||
|
"system": True,
|
||||||
|
},
|
||||||
|
ip_address=None,
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
else:
|
||||||
|
# Still banned - return ban info in error
|
||||||
|
ban_info = {
|
||||||
|
"banned_at": user.banned_at.isoformat() if user.banned_at else None,
|
||||||
|
"banned_until": user.banned_until.isoformat() if user.banned_until else None,
|
||||||
|
"reason": user.ban_reason,
|
||||||
|
}
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=ban_info,
|
||||||
|
)
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -56,6 +101,21 @@ def require_admin(user: User) -> User:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin_with_2fa(user: User) -> User:
|
||||||
|
"""Check if user is admin with Telegram linked (2FA enabled)"""
|
||||||
|
if not user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Admin access required",
|
||||||
|
)
|
||||||
|
if not user.telegram_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Для доступа к админ-панели необходимо привязать Telegram в профиле",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def get_participant(
|
async def get_participant(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram
|
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
@@ -15,3 +15,4 @@ router.include_router(admin.router)
|
|||||||
router.include_router(events.router)
|
router.include_router(events.router)
|
||||||
router.include_router(assignments.router)
|
router.include_router(assignments.router)
|
||||||
router.include_router(telegram.router)
|
router.include_router(telegram.router)
|
||||||
|
router.include_router(content.router)
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
from fastapi import APIRouter, HTTPException, Query
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser, require_admin
|
from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
|
||||||
from app.models import User, UserRole, Marathon, Participant, Game
|
from app.models import User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent
|
||||||
from app.schemas import UserPublic, MarathonListItem, MessageResponse
|
from app.schemas import (
|
||||||
|
UserPublic, MessageResponse,
|
||||||
|
AdminUserResponse, BanUserRequest, AdminResetPasswordRequest, AdminLogResponse, AdminLogsListResponse,
|
||||||
|
BroadcastRequest, BroadcastResponse, StaticContentResponse, StaticContentUpdate,
|
||||||
|
StaticContentCreate, DashboardStats
|
||||||
|
)
|
||||||
|
from app.core.security import get_password_hash
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
from app.core.rate_limit import limiter
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
@@ -14,21 +23,6 @@ class SetUserRole(BaseModel):
|
|||||||
role: str = Field(..., pattern="^(user|admin)$")
|
role: str = Field(..., pattern="^(user|admin)$")
|
||||||
|
|
||||||
|
|
||||||
class AdminUserResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
login: str
|
|
||||||
nickname: str
|
|
||||||
role: str
|
|
||||||
avatar_url: str | None = None
|
|
||||||
telegram_id: int | None = None
|
|
||||||
telegram_username: str | None = None
|
|
||||||
marathons_count: int = 0
|
|
||||||
created_at: str
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
class AdminMarathonResponse(BaseModel):
|
class AdminMarathonResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
@@ -44,6 +38,29 @@ class AdminMarathonResponse(BaseModel):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Helper Functions ============
|
||||||
|
async def log_admin_action(
|
||||||
|
db,
|
||||||
|
admin_id: int,
|
||||||
|
action: str,
|
||||||
|
target_type: str,
|
||||||
|
target_id: int,
|
||||||
|
details: dict | None = None,
|
||||||
|
ip_address: str | None = None
|
||||||
|
):
|
||||||
|
"""Log an admin action."""
|
||||||
|
log = AdminLog(
|
||||||
|
admin_id=admin_id,
|
||||||
|
action=action,
|
||||||
|
target_type=target_type,
|
||||||
|
target_id=target_id,
|
||||||
|
details=details,
|
||||||
|
ip_address=ip_address,
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users", response_model=list[AdminUserResponse])
|
@router.get("/users", response_model=list[AdminUserResponse])
|
||||||
async def list_users(
|
async def list_users(
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
@@ -51,9 +68,10 @@ async def list_users(
|
|||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=100),
|
limit: int = Query(50, ge=1, le=100),
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
|
banned_only: bool = False,
|
||||||
):
|
):
|
||||||
"""List all users. Admin only."""
|
"""List all users. Admin only."""
|
||||||
require_admin(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
query = select(User).order_by(User.created_at.desc())
|
query = select(User).order_by(User.created_at.desc())
|
||||||
|
|
||||||
@@ -63,6 +81,9 @@ async def list_users(
|
|||||||
(User.nickname.ilike(f"%{search}%"))
|
(User.nickname.ilike(f"%{search}%"))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if banned_only:
|
||||||
|
query = query.where(User.is_banned == True)
|
||||||
|
|
||||||
query = query.offset(skip).limit(limit)
|
query = query.offset(skip).limit(limit)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
users = result.scalars().all()
|
users = result.scalars().all()
|
||||||
@@ -83,6 +104,10 @@ async def list_users(
|
|||||||
telegram_username=user.telegram_username,
|
telegram_username=user.telegram_username,
|
||||||
marathons_count=marathons_count,
|
marathons_count=marathons_count,
|
||||||
created_at=user.created_at.isoformat(),
|
created_at=user.created_at.isoformat(),
|
||||||
|
is_banned=user.is_banned,
|
||||||
|
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
||||||
|
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
||||||
|
ban_reason=user.ban_reason,
|
||||||
))
|
))
|
||||||
|
|
||||||
return response
|
return response
|
||||||
@@ -91,7 +116,7 @@ async def list_users(
|
|||||||
@router.get("/users/{user_id}", response_model=AdminUserResponse)
|
@router.get("/users/{user_id}", response_model=AdminUserResponse)
|
||||||
async def get_user(user_id: int, current_user: CurrentUser, db: DbSession):
|
async def get_user(user_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
"""Get user details. Admin only."""
|
"""Get user details. Admin only."""
|
||||||
require_admin(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
result = await db.execute(select(User).where(User.id == user_id))
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
@@ -112,6 +137,10 @@ async def get_user(user_id: int, current_user: CurrentUser, db: DbSession):
|
|||||||
telegram_username=user.telegram_username,
|
telegram_username=user.telegram_username,
|
||||||
marathons_count=marathons_count,
|
marathons_count=marathons_count,
|
||||||
created_at=user.created_at.isoformat(),
|
created_at=user.created_at.isoformat(),
|
||||||
|
is_banned=user.is_banned,
|
||||||
|
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
||||||
|
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
||||||
|
ban_reason=user.ban_reason,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -121,9 +150,10 @@ async def set_user_role(
|
|||||||
data: SetUserRole,
|
data: SetUserRole,
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
|
request: Request,
|
||||||
):
|
):
|
||||||
"""Set user's global role. Admin only."""
|
"""Set user's global role. Admin only."""
|
||||||
require_admin(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
# Cannot change own role
|
# Cannot change own role
|
||||||
if user_id == current_user.id:
|
if user_id == current_user.id:
|
||||||
@@ -134,10 +164,19 @@ async def set_user_role(
|
|||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
old_role = user.role
|
||||||
user.role = data.role
|
user.role = data.role
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.USER_ROLE_CHANGE.value,
|
||||||
|
"user", user_id,
|
||||||
|
{"old_role": old_role, "new_role": data.role, "nickname": user.nickname},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
marathons_count = await db.scalar(
|
marathons_count = await db.scalar(
|
||||||
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
)
|
)
|
||||||
@@ -152,13 +191,17 @@ async def set_user_role(
|
|||||||
telegram_username=user.telegram_username,
|
telegram_username=user.telegram_username,
|
||||||
marathons_count=marathons_count,
|
marathons_count=marathons_count,
|
||||||
created_at=user.created_at.isoformat(),
|
created_at=user.created_at.isoformat(),
|
||||||
|
is_banned=user.is_banned,
|
||||||
|
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
||||||
|
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
||||||
|
ban_reason=user.ban_reason,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/users/{user_id}", response_model=MessageResponse)
|
@router.delete("/users/{user_id}", response_model=MessageResponse)
|
||||||
async def delete_user(user_id: int, current_user: CurrentUser, db: DbSession):
|
async def delete_user(user_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
"""Delete a user. Admin only."""
|
"""Delete a user. Admin only."""
|
||||||
require_admin(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
# Cannot delete yourself
|
# Cannot delete yourself
|
||||||
if user_id == current_user.id:
|
if user_id == current_user.id:
|
||||||
@@ -188,7 +231,7 @@ async def list_marathons(
|
|||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
):
|
):
|
||||||
"""List all marathons. Admin only."""
|
"""List all marathons. Admin only."""
|
||||||
require_admin(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(Marathon)
|
select(Marathon)
|
||||||
@@ -227,25 +270,34 @@ async def list_marathons(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/marathons/{marathon_id}", response_model=MessageResponse)
|
@router.delete("/marathons/{marathon_id}", response_model=MessageResponse)
|
||||||
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession, request: Request):
|
||||||
"""Delete a marathon. Admin only."""
|
"""Delete a marathon. Admin only."""
|
||||||
require_admin(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
marathon = result.scalar_one_or_none()
|
marathon = result.scalar_one_or_none()
|
||||||
if not marathon:
|
if not marathon:
|
||||||
raise HTTPException(status_code=404, detail="Marathon not found")
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
marathon_title = marathon.title
|
||||||
await db.delete(marathon)
|
await db.delete(marathon)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.MARATHON_DELETE.value,
|
||||||
|
"marathon", marathon_id,
|
||||||
|
{"title": marathon_title},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
return MessageResponse(message="Marathon deleted")
|
return MessageResponse(message="Marathon deleted")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
async def get_stats(current_user: CurrentUser, db: DbSession):
|
async def get_stats(current_user: CurrentUser, db: DbSession):
|
||||||
"""Get platform statistics. Admin only."""
|
"""Get platform statistics. Admin only."""
|
||||||
require_admin(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
users_count = await db.scalar(select(func.count()).select_from(User))
|
users_count = await db.scalar(select(func.count()).select_from(User))
|
||||||
marathons_count = await db.scalar(select(func.count()).select_from(Marathon))
|
marathons_count = await db.scalar(select(func.count()).select_from(Marathon))
|
||||||
@@ -258,3 +310,530 @@ async def get_stats(current_user: CurrentUser, db: DbSession):
|
|||||||
"games_count": games_count,
|
"games_count": games_count,
|
||||||
"total_participations": participants_count,
|
"total_participations": participants_count,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Ban/Unban Users ============
|
||||||
|
@router.post("/users/{user_id}/ban", response_model=AdminUserResponse)
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def ban_user(
|
||||||
|
request: Request,
|
||||||
|
user_id: int,
|
||||||
|
data: BanUserRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Ban a user. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
if user_id == current_user.id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot ban yourself")
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
if user.role == UserRole.ADMIN.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot ban another admin")
|
||||||
|
|
||||||
|
if user.is_banned:
|
||||||
|
raise HTTPException(status_code=400, detail="User is already banned")
|
||||||
|
|
||||||
|
user.is_banned = True
|
||||||
|
user.banned_at = datetime.utcnow()
|
||||||
|
# Normalize to naive datetime (remove tzinfo) to match banned_at
|
||||||
|
user.banned_until = data.banned_until.replace(tzinfo=None) if data.banned_until else None
|
||||||
|
user.banned_by_id = current_user.id
|
||||||
|
user.ban_reason = data.reason
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.USER_BAN.value,
|
||||||
|
"user", user_id,
|
||||||
|
{"nickname": user.nickname, "reason": data.reason},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
marathons_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AdminUserResponse(
|
||||||
|
id=user.id,
|
||||||
|
login=user.login,
|
||||||
|
nickname=user.nickname,
|
||||||
|
role=user.role,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
telegram_id=user.telegram_id,
|
||||||
|
telegram_username=user.telegram_username,
|
||||||
|
marathons_count=marathons_count,
|
||||||
|
created_at=user.created_at.isoformat(),
|
||||||
|
is_banned=user.is_banned,
|
||||||
|
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
||||||
|
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
||||||
|
ban_reason=user.ban_reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/unban", response_model=AdminUserResponse)
|
||||||
|
async def unban_user(
|
||||||
|
request: Request,
|
||||||
|
user_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Unban a user. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
if not user.is_banned:
|
||||||
|
raise HTTPException(status_code=400, detail="User is not banned")
|
||||||
|
|
||||||
|
user.is_banned = False
|
||||||
|
user.banned_at = None
|
||||||
|
user.banned_until = None
|
||||||
|
user.banned_by_id = None
|
||||||
|
user.ban_reason = None
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.USER_UNBAN.value,
|
||||||
|
"user", user_id,
|
||||||
|
{"nickname": user.nickname},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
marathons_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AdminUserResponse(
|
||||||
|
id=user.id,
|
||||||
|
login=user.login,
|
||||||
|
nickname=user.nickname,
|
||||||
|
role=user.role,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
telegram_id=user.telegram_id,
|
||||||
|
telegram_username=user.telegram_username,
|
||||||
|
marathons_count=marathons_count,
|
||||||
|
created_at=user.created_at.isoformat(),
|
||||||
|
is_banned=user.is_banned,
|
||||||
|
banned_at=None,
|
||||||
|
banned_until=None,
|
||||||
|
ban_reason=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ 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 ============
|
||||||
|
@router.post("/marathons/{marathon_id}/force-finish", response_model=MessageResponse)
|
||||||
|
async def force_finish_marathon(
|
||||||
|
request: Request,
|
||||||
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Force finish a marathon. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if not marathon:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
if marathon.status == MarathonStatus.FINISHED.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Marathon is already finished")
|
||||||
|
|
||||||
|
old_status = marathon.status
|
||||||
|
marathon.status = MarathonStatus.FINISHED.value
|
||||||
|
marathon.end_date = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.MARATHON_FORCE_FINISH.value,
|
||||||
|
"marathon", marathon_id,
|
||||||
|
{"title": marathon.title, "old_status": old_status},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify participants
|
||||||
|
await telegram_notifier.notify_marathon_finish(db, marathon_id, marathon.title)
|
||||||
|
|
||||||
|
return MessageResponse(message="Marathon finished")
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Admin Logs ============
|
||||||
|
@router.get("/logs", response_model=AdminLogsListResponse)
|
||||||
|
async def get_logs(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=100),
|
||||||
|
action: str | None = None,
|
||||||
|
admin_id: int | None = None,
|
||||||
|
):
|
||||||
|
"""Get admin action logs. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
query = (
|
||||||
|
select(AdminLog)
|
||||||
|
.options(selectinload(AdminLog.admin))
|
||||||
|
.order_by(AdminLog.created_at.desc())
|
||||||
|
)
|
||||||
|
|
||||||
|
if action:
|
||||||
|
query = query.where(AdminLog.action == action)
|
||||||
|
if admin_id:
|
||||||
|
query = query.where(AdminLog.admin_id == admin_id)
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
count_query = select(func.count()).select_from(AdminLog)
|
||||||
|
if action:
|
||||||
|
count_query = count_query.where(AdminLog.action == action)
|
||||||
|
if admin_id:
|
||||||
|
count_query = count_query.where(AdminLog.admin_id == admin_id)
|
||||||
|
total = await db.scalar(count_query)
|
||||||
|
|
||||||
|
query = query.offset(skip).limit(limit)
|
||||||
|
result = await db.execute(query)
|
||||||
|
logs = result.scalars().all()
|
||||||
|
|
||||||
|
return AdminLogsListResponse(
|
||||||
|
logs=[
|
||||||
|
AdminLogResponse(
|
||||||
|
id=log.id,
|
||||||
|
admin_id=log.admin_id,
|
||||||
|
admin_nickname=log.admin.nickname if log.admin else None,
|
||||||
|
action=log.action,
|
||||||
|
target_type=log.target_type,
|
||||||
|
target_id=log.target_id,
|
||||||
|
details=log.details,
|
||||||
|
ip_address=log.ip_address,
|
||||||
|
created_at=log.created_at,
|
||||||
|
)
|
||||||
|
for log in logs
|
||||||
|
],
|
||||||
|
total=total or 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Broadcast ============
|
||||||
|
@router.post("/broadcast/all", response_model=BroadcastResponse)
|
||||||
|
@limiter.limit("1/minute")
|
||||||
|
async def broadcast_to_all(
|
||||||
|
request: Request,
|
||||||
|
data: BroadcastRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Send broadcast message to all users with Telegram linked. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
# Get all users with telegram_id
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id.isnot(None))
|
||||||
|
)
|
||||||
|
users = result.scalars().all()
|
||||||
|
|
||||||
|
total_count = len(users)
|
||||||
|
sent_count = 0
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
if await telegram_notifier.send_message(user.telegram_id, data.message):
|
||||||
|
sent_count += 1
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.BROADCAST_ALL.value,
|
||||||
|
"broadcast", 0,
|
||||||
|
{"message": data.message[:100], "sent": sent_count, "total": total_count},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return BroadcastResponse(sent_count=sent_count, total_count=total_count)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/broadcast/marathon/{marathon_id}", response_model=BroadcastResponse)
|
||||||
|
@limiter.limit("3/minute")
|
||||||
|
async def broadcast_to_marathon(
|
||||||
|
request: Request,
|
||||||
|
marathon_id: int,
|
||||||
|
data: BroadcastRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Send broadcast message to marathon participants. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
# Check marathon exists
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if not marathon:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
# Get participants count
|
||||||
|
total_result = await db.execute(
|
||||||
|
select(User)
|
||||||
|
.join(Participant, Participant.user_id == User.id)
|
||||||
|
.where(
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
User.telegram_id.isnot(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
users = total_result.scalars().all()
|
||||||
|
total_count = len(users)
|
||||||
|
|
||||||
|
sent_count = await telegram_notifier.notify_marathon_participants(
|
||||||
|
db, marathon_id, data.message
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.BROADCAST_MARATHON.value,
|
||||||
|
"marathon", marathon_id,
|
||||||
|
{"title": marathon.title, "message": data.message[:100], "sent": sent_count, "total": total_count},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return BroadcastResponse(sent_count=sent_count, total_count=total_count)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Static Content ============
|
||||||
|
@router.get("/content", response_model=list[StaticContentResponse])
|
||||||
|
async def list_content(current_user: CurrentUser, db: DbSession):
|
||||||
|
"""List all static content. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(StaticContent).order_by(StaticContent.key)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/content/{key}", response_model=StaticContentResponse)
|
||||||
|
async def get_content(key: str, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Get static content by key. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(StaticContent).where(StaticContent.key == key)
|
||||||
|
)
|
||||||
|
content = result.scalar_one_or_none()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=404, detail="Content not found")
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/content/{key}", response_model=StaticContentResponse)
|
||||||
|
async def update_content(
|
||||||
|
request: Request,
|
||||||
|
key: str,
|
||||||
|
data: StaticContentUpdate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Update static content. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(StaticContent).where(StaticContent.key == key)
|
||||||
|
)
|
||||||
|
content = result.scalar_one_or_none()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=404, detail="Content not found")
|
||||||
|
|
||||||
|
content.title = data.title
|
||||||
|
content.content = data.content
|
||||||
|
content.updated_by_id = current_user.id
|
||||||
|
content.updated_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(content)
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.CONTENT_UPDATE.value,
|
||||||
|
"content", content.id,
|
||||||
|
{"key": key, "title": data.title},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/content", response_model=StaticContentResponse)
|
||||||
|
async def create_content(
|
||||||
|
request: Request,
|
||||||
|
data: StaticContentCreate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Create static content. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
# Check if key exists
|
||||||
|
result = await db.execute(
|
||||||
|
select(StaticContent).where(StaticContent.key == data.key)
|
||||||
|
)
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="Content with this key already exists")
|
||||||
|
|
||||||
|
content = StaticContent(
|
||||||
|
key=data.key,
|
||||||
|
title=data.title,
|
||||||
|
content=data.content,
|
||||||
|
updated_by_id=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(content)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(content)
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
@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 ============
|
||||||
|
@router.get("/dashboard", response_model=DashboardStats)
|
||||||
|
async def get_dashboard(current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Get dashboard statistics. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
users_count = await db.scalar(select(func.count()).select_from(User))
|
||||||
|
banned_users_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(User).where(User.is_banned == True)
|
||||||
|
)
|
||||||
|
marathons_count = await db.scalar(select(func.count()).select_from(Marathon))
|
||||||
|
active_marathons_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Marathon).where(Marathon.status == MarathonStatus.ACTIVE.value)
|
||||||
|
)
|
||||||
|
games_count = await db.scalar(select(func.count()).select_from(Game))
|
||||||
|
total_participations = await db.scalar(select(func.count()).select_from(Participant))
|
||||||
|
|
||||||
|
# Get recent logs
|
||||||
|
result = await db.execute(
|
||||||
|
select(AdminLog)
|
||||||
|
.options(selectinload(AdminLog.admin))
|
||||||
|
.order_by(AdminLog.created_at.desc())
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
recent_logs = result.scalars().all()
|
||||||
|
|
||||||
|
return DashboardStats(
|
||||||
|
users_count=users_count or 0,
|
||||||
|
banned_users_count=banned_users_count or 0,
|
||||||
|
marathons_count=marathons_count or 0,
|
||||||
|
active_marathons_count=active_marathons_count or 0,
|
||||||
|
games_count=games_count or 0,
|
||||||
|
total_participations=total_participations or 0,
|
||||||
|
recent_logs=[
|
||||||
|
AdminLogResponse(
|
||||||
|
id=log.id,
|
||||||
|
admin_id=log.admin_id,
|
||||||
|
admin_nickname=log.admin.nickname if log.admin else None,
|
||||||
|
action=log.action,
|
||||||
|
target_type=log.target_type,
|
||||||
|
target_id=log.target_id,
|
||||||
|
details=log.details,
|
||||||
|
ip_address=log.ip_address,
|
||||||
|
created_at=log.created_at,
|
||||||
|
)
|
||||||
|
for log in recent_logs
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
import secrets
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, status, Request
|
from fastapi import APIRouter, HTTPException, status, Request
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser
|
from app.api.deps import DbSession, CurrentUser
|
||||||
from app.core.security import verify_password, get_password_hash, create_access_token
|
from app.core.security import verify_password, get_password_hash, create_access_token
|
||||||
from app.core.rate_limit import limiter
|
from app.core.rate_limit import limiter
|
||||||
from app.models import User
|
from app.models import User, UserRole, Admin2FASession
|
||||||
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate
|
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate, LoginResponse
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
@@ -40,7 +44,7 @@ async def register(request: Request, data: UserRegister, db: DbSession):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=LoginResponse)
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def login(request: Request, data: UserLogin, db: DbSession):
|
async def login(request: Request, data: UserLogin, db: DbSession):
|
||||||
# Find user
|
# Find user
|
||||||
@@ -53,6 +57,99 @@ async def login(request: Request, data: UserLogin, db: DbSession):
|
|||||||
detail="Incorrect login or password",
|
detail="Incorrect login or password",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check if user is banned
|
||||||
|
if user.is_banned:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Your account has been banned",
|
||||||
|
)
|
||||||
|
|
||||||
|
# If admin with Telegram linked, require 2FA
|
||||||
|
if user.role == UserRole.ADMIN.value and user.telegram_id:
|
||||||
|
# Generate 6-digit code
|
||||||
|
code = "".join([str(secrets.randbelow(10)) for _ in range(6)])
|
||||||
|
|
||||||
|
# Create 2FA session (expires in 5 minutes)
|
||||||
|
session = Admin2FASession(
|
||||||
|
user_id=user.id,
|
||||||
|
code=code,
|
||||||
|
expires_at=datetime.utcnow() + timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
db.add(session)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(session)
|
||||||
|
|
||||||
|
# Send code to Telegram
|
||||||
|
message = f"🔐 <b>Код подтверждения для входа в админку</b>\n\nВаш код: <code>{code}</code>\n\nКод действителен 5 минут."
|
||||||
|
sent = await telegram_notifier.send_message(user.telegram_id, message)
|
||||||
|
|
||||||
|
if sent:
|
||||||
|
session.telegram_sent = True
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return LoginResponse(
|
||||||
|
requires_2fa=True,
|
||||||
|
two_factor_session_id=session.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Regular user or admin without Telegram - generate token immediately
|
||||||
|
# Admin without Telegram can login but admin panel will check for Telegram
|
||||||
|
access_token = create_access_token(subject=user.id)
|
||||||
|
|
||||||
|
return LoginResponse(
|
||||||
|
access_token=access_token,
|
||||||
|
user=UserPrivate.model_validate(user),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/2fa/verify", response_model=TokenResponse)
|
||||||
|
@limiter.limit("5/minute")
|
||||||
|
async def verify_2fa(request: Request, session_id: int, code: str, db: DbSession):
|
||||||
|
"""Verify 2FA code and return JWT token."""
|
||||||
|
# Find session
|
||||||
|
result = await db.execute(
|
||||||
|
select(Admin2FASession).where(Admin2FASession.id == session_id)
|
||||||
|
)
|
||||||
|
session = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid session",
|
||||||
|
)
|
||||||
|
|
||||||
|
if session.is_verified:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Session already verified",
|
||||||
|
)
|
||||||
|
|
||||||
|
if datetime.utcnow() > session.expires_at:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Code expired",
|
||||||
|
)
|
||||||
|
|
||||||
|
if session.code != code:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid code",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark as verified
|
||||||
|
session.is_verified = True
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Get user
|
||||||
|
result = await db.execute(select(User).where(User.id == session.user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="User not found",
|
||||||
|
)
|
||||||
|
|
||||||
# Generate token
|
# Generate token
|
||||||
access_token = create_access_token(subject=user.id)
|
access_token = create_access_token(subject=user.id)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser, require_participant, require_organizer, get_participant
|
from app.api.deps import DbSession, CurrentUser, require_participant, require_organizer, get_participant
|
||||||
from app.models import Marathon, MarathonStatus, Game, GameStatus, Challenge
|
from app.models import Marathon, MarathonStatus, Game, GameStatus, Challenge, User
|
||||||
|
from app.models.challenge import ChallengeStatus
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
ChallengeCreate,
|
ChallengeCreate,
|
||||||
ChallengeUpdate,
|
ChallengeUpdate,
|
||||||
@@ -15,7 +16,9 @@ from app.schemas import (
|
|||||||
ChallengesSaveRequest,
|
ChallengesSaveRequest,
|
||||||
ChallengesGenerateRequest,
|
ChallengesGenerateRequest,
|
||||||
)
|
)
|
||||||
|
from app.schemas.challenge import ChallengePropose, ProposedByUser
|
||||||
from app.services.gpt import gpt_service
|
from app.services.gpt import gpt_service
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
router = APIRouter(tags=["challenges"])
|
router = APIRouter(tags=["challenges"])
|
||||||
|
|
||||||
@@ -23,7 +26,7 @@ router = APIRouter(tags=["challenges"])
|
|||||||
async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
|
async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Challenge)
|
select(Challenge)
|
||||||
.options(selectinload(Challenge.game))
|
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||||
.where(Challenge.id == challenge_id)
|
.where(Challenge.id == challenge_id)
|
||||||
)
|
)
|
||||||
challenge = result.scalar_one_or_none()
|
challenge = result.scalar_one_or_none()
|
||||||
@@ -32,9 +35,36 @@ async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
|
|||||||
return challenge
|
return challenge
|
||||||
|
|
||||||
|
|
||||||
|
def build_challenge_response(challenge: Challenge, game: Game) -> ChallengeResponse:
|
||||||
|
"""Helper to build ChallengeResponse with proposed_by"""
|
||||||
|
proposed_by = None
|
||||||
|
if challenge.proposed_by:
|
||||||
|
proposed_by = ProposedByUser(
|
||||||
|
id=challenge.proposed_by.id,
|
||||||
|
nickname=challenge.proposed_by.nickname
|
||||||
|
)
|
||||||
|
|
||||||
|
return ChallengeResponse(
|
||||||
|
id=challenge.id,
|
||||||
|
title=challenge.title,
|
||||||
|
description=challenge.description,
|
||||||
|
type=challenge.type,
|
||||||
|
difficulty=challenge.difficulty,
|
||||||
|
points=challenge.points,
|
||||||
|
estimated_time=challenge.estimated_time,
|
||||||
|
proof_type=challenge.proof_type,
|
||||||
|
proof_hint=challenge.proof_hint,
|
||||||
|
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
||||||
|
is_generated=challenge.is_generated,
|
||||||
|
created_at=challenge.created_at,
|
||||||
|
status=challenge.status,
|
||||||
|
proposed_by=proposed_by,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/games/{game_id}/challenges", response_model=list[ChallengeResponse])
|
@router.get("/games/{game_id}/challenges", response_model=list[ChallengeResponse])
|
||||||
async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession):
|
async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
"""List challenges for a game. Participants can view challenges for approved games only."""
|
"""List challenges for a game. Participants can view approved and pending challenges."""
|
||||||
# Get game and check access
|
# Get game and check access
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Game).where(Game.id == game_id)
|
select(Game).where(Game.id == game_id)
|
||||||
@@ -54,30 +84,17 @@ async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession
|
|||||||
if game.status != GameStatus.APPROVED.value and game.proposed_by_id != current_user.id:
|
if game.status != GameStatus.APPROVED.value and game.proposed_by_id != current_user.id:
|
||||||
raise HTTPException(status_code=403, detail="Game not accessible")
|
raise HTTPException(status_code=403, detail="Game not accessible")
|
||||||
|
|
||||||
result = await db.execute(
|
# Get challenges with proposed_by
|
||||||
select(Challenge)
|
query = select(Challenge).options(selectinload(Challenge.proposed_by)).where(Challenge.game_id == game_id)
|
||||||
.where(Challenge.game_id == game_id)
|
|
||||||
.order_by(Challenge.difficulty, Challenge.created_at)
|
# Regular participants see approved and pending challenges (but not rejected)
|
||||||
)
|
if not current_user.is_admin and participant and not participant.is_organizer:
|
||||||
|
query = query.where(Challenge.status.in_([ChallengeStatus.APPROVED.value, ChallengeStatus.PENDING.value]))
|
||||||
|
|
||||||
|
result = await db.execute(query.order_by(Challenge.status.desc(), Challenge.difficulty, Challenge.created_at))
|
||||||
challenges = result.scalars().all()
|
challenges = result.scalars().all()
|
||||||
|
|
||||||
return [
|
return [build_challenge_response(c, game) for c in challenges]
|
||||||
ChallengeResponse(
|
|
||||||
id=c.id,
|
|
||||||
title=c.title,
|
|
||||||
description=c.description,
|
|
||||||
type=c.type,
|
|
||||||
difficulty=c.difficulty,
|
|
||||||
points=c.points,
|
|
||||||
estimated_time=c.estimated_time,
|
|
||||||
proof_type=c.proof_type,
|
|
||||||
proof_hint=c.proof_hint,
|
|
||||||
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
|
||||||
is_generated=c.is_generated,
|
|
||||||
created_at=c.created_at,
|
|
||||||
)
|
|
||||||
for c in challenges
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/marathons/{marathon_id}/challenges", response_model=list[ChallengeResponse])
|
@router.get("/marathons/{marathon_id}/challenges", response_model=list[ChallengeResponse])
|
||||||
@@ -94,36 +111,21 @@ async def list_marathon_challenges(marathon_id: int, current_user: CurrentUser,
|
|||||||
if not current_user.is_admin and not participant:
|
if not current_user.is_admin and not participant:
|
||||||
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
# Get all challenges from approved games in this marathon
|
# Get all approved challenges from approved games in this marathon
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Challenge)
|
select(Challenge)
|
||||||
.join(Game, Challenge.game_id == Game.id)
|
.join(Game, Challenge.game_id == Game.id)
|
||||||
.options(selectinload(Challenge.game))
|
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||||
.where(
|
.where(
|
||||||
Game.marathon_id == marathon_id,
|
Game.marathon_id == marathon_id,
|
||||||
Game.status == GameStatus.APPROVED.value,
|
Game.status == GameStatus.APPROVED.value,
|
||||||
|
Challenge.status == ChallengeStatus.APPROVED.value,
|
||||||
)
|
)
|
||||||
.order_by(Game.title, Challenge.difficulty, Challenge.created_at)
|
.order_by(Game.title, Challenge.difficulty, Challenge.created_at)
|
||||||
)
|
)
|
||||||
challenges = result.scalars().all()
|
challenges = result.scalars().all()
|
||||||
|
|
||||||
return [
|
return [build_challenge_response(c, c.game) for c in challenges]
|
||||||
ChallengeResponse(
|
|
||||||
id=c.id,
|
|
||||||
title=c.title,
|
|
||||||
description=c.description,
|
|
||||||
type=c.type,
|
|
||||||
difficulty=c.difficulty,
|
|
||||||
points=c.points,
|
|
||||||
estimated_time=c.estimated_time,
|
|
||||||
proof_type=c.proof_type,
|
|
||||||
proof_hint=c.proof_hint,
|
|
||||||
game=GameShort(id=c.game.id, title=c.game.title, cover_url=None),
|
|
||||||
is_generated=c.is_generated,
|
|
||||||
created_at=c.created_at,
|
|
||||||
)
|
|
||||||
for c in challenges
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse)
|
@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse)
|
||||||
@@ -166,25 +168,13 @@ async def create_challenge(
|
|||||||
proof_type=data.proof_type.value,
|
proof_type=data.proof_type.value,
|
||||||
proof_hint=data.proof_hint,
|
proof_hint=data.proof_hint,
|
||||||
is_generated=False,
|
is_generated=False,
|
||||||
|
status=ChallengeStatus.APPROVED.value, # Organizer-created challenges are auto-approved
|
||||||
)
|
)
|
||||||
db.add(challenge)
|
db.add(challenge)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(challenge)
|
await db.refresh(challenge)
|
||||||
|
|
||||||
return ChallengeResponse(
|
return build_challenge_response(challenge, game)
|
||||||
id=challenge.id,
|
|
||||||
title=challenge.title,
|
|
||||||
description=challenge.description,
|
|
||||||
type=challenge.type,
|
|
||||||
difficulty=challenge.difficulty,
|
|
||||||
points=challenge.points,
|
|
||||||
estimated_time=challenge.estimated_time,
|
|
||||||
proof_type=challenge.proof_type,
|
|
||||||
proof_hint=challenge.proof_hint,
|
|
||||||
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
|
||||||
is_generated=challenge.is_generated,
|
|
||||||
created_at=challenge.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse)
|
@router.post("/marathons/{marathon_id}/preview-challenges", response_model=ChallengesPreviewResponse)
|
||||||
@@ -386,26 +376,12 @@ async def update_challenge(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(challenge)
|
await db.refresh(challenge)
|
||||||
|
|
||||||
game = challenge.game
|
return build_challenge_response(challenge, challenge.game)
|
||||||
return ChallengeResponse(
|
|
||||||
id=challenge.id,
|
|
||||||
title=challenge.title,
|
|
||||||
description=challenge.description,
|
|
||||||
type=challenge.type,
|
|
||||||
difficulty=challenge.difficulty,
|
|
||||||
points=challenge.points,
|
|
||||||
estimated_time=challenge.estimated_time,
|
|
||||||
proof_type=challenge.proof_type,
|
|
||||||
proof_hint=challenge.proof_hint,
|
|
||||||
game=GameShort(id=game.id, title=game.title, cover_url=None),
|
|
||||||
is_generated=challenge.is_generated,
|
|
||||||
created_at=challenge.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/challenges/{challenge_id}", response_model=MessageResponse)
|
@router.delete("/challenges/{challenge_id}", response_model=MessageResponse)
|
||||||
async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
|
async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
"""Delete a challenge. Organizers only."""
|
"""Delete a challenge. Organizers can delete any, participants can delete their own pending."""
|
||||||
challenge = await get_challenge_or_404(db, challenge_id)
|
challenge = await get_challenge_or_404(db, challenge_id)
|
||||||
|
|
||||||
# Check marathon is in preparing state
|
# Check marathon is in preparing state
|
||||||
@@ -414,10 +390,206 @@ async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbS
|
|||||||
if marathon.status != MarathonStatus.PREPARING.value:
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
raise HTTPException(status_code=400, detail="Cannot delete challenges from active or finished marathon")
|
raise HTTPException(status_code=400, detail="Cannot delete challenges from active or finished marathon")
|
||||||
|
|
||||||
# Only organizers can delete challenges
|
participant = await get_participant(db, current_user.id, challenge.game.marathon_id)
|
||||||
await require_organizer(db, current_user, challenge.game.marathon_id)
|
|
||||||
|
# Check permissions
|
||||||
|
if current_user.is_admin or (participant and participant.is_organizer):
|
||||||
|
# Organizers can delete any challenge
|
||||||
|
pass
|
||||||
|
elif challenge.proposed_by_id == current_user.id and challenge.status == ChallengeStatus.PENDING.value:
|
||||||
|
# Participants can delete their own pending challenges
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=403, detail="You can only delete your own pending challenges")
|
||||||
|
|
||||||
await db.delete(challenge)
|
await db.delete(challenge)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return MessageResponse(message="Challenge deleted")
|
return MessageResponse(message="Challenge deleted")
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Proposed challenges endpoints ============
|
||||||
|
|
||||||
|
@router.post("/games/{game_id}/propose-challenge", response_model=ChallengeResponse)
|
||||||
|
async def propose_challenge(
|
||||||
|
game_id: int,
|
||||||
|
data: ChallengePropose,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Propose a challenge for a game. Participants only, during PREPARING phase."""
|
||||||
|
# Get game
|
||||||
|
result = await db.execute(select(Game).where(Game.id == game_id))
|
||||||
|
game = result.scalar_one_or_none()
|
||||||
|
if not game:
|
||||||
|
raise HTTPException(status_code=404, detail="Game not found")
|
||||||
|
|
||||||
|
# Check marathon is in preparing state
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
|
||||||
|
marathon = result.scalar_one()
|
||||||
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot propose challenges to active or finished marathon")
|
||||||
|
|
||||||
|
# Check user is participant
|
||||||
|
participant = await get_participant(db, current_user.id, game.marathon_id)
|
||||||
|
if not participant and not current_user.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
|
# Can only propose challenges to approved games
|
||||||
|
if game.status != GameStatus.APPROVED.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Can only propose challenges to approved games")
|
||||||
|
|
||||||
|
challenge = Challenge(
|
||||||
|
game_id=game_id,
|
||||||
|
title=data.title,
|
||||||
|
description=data.description,
|
||||||
|
type=data.type.value,
|
||||||
|
difficulty=data.difficulty.value,
|
||||||
|
points=data.points,
|
||||||
|
estimated_time=data.estimated_time,
|
||||||
|
proof_type=data.proof_type.value,
|
||||||
|
proof_hint=data.proof_hint,
|
||||||
|
is_generated=False,
|
||||||
|
proposed_by_id=current_user.id,
|
||||||
|
status=ChallengeStatus.PENDING.value,
|
||||||
|
)
|
||||||
|
db.add(challenge)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(challenge)
|
||||||
|
|
||||||
|
# Load proposed_by relationship
|
||||||
|
challenge.proposed_by = current_user
|
||||||
|
|
||||||
|
return build_challenge_response(challenge, game)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathons/{marathon_id}/proposed-challenges", response_model=list[ChallengeResponse])
|
||||||
|
async def list_proposed_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""List all pending proposed challenges for a marathon. Organizers only."""
|
||||||
|
# Check marathon exists
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if not marathon:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
# Only organizers can see all proposed challenges
|
||||||
|
await require_organizer(db, current_user, marathon_id)
|
||||||
|
|
||||||
|
# Get all pending challenges from approved games
|
||||||
|
result = await db.execute(
|
||||||
|
select(Challenge)
|
||||||
|
.join(Game, Challenge.game_id == Game.id)
|
||||||
|
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||||
|
.where(
|
||||||
|
Game.marathon_id == marathon_id,
|
||||||
|
Game.status == GameStatus.APPROVED.value,
|
||||||
|
Challenge.status == ChallengeStatus.PENDING.value,
|
||||||
|
)
|
||||||
|
.order_by(Challenge.created_at.desc())
|
||||||
|
)
|
||||||
|
challenges = result.scalars().all()
|
||||||
|
|
||||||
|
return [build_challenge_response(c, c.game) for c in challenges]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathons/{marathon_id}/my-proposed-challenges", response_model=list[ChallengeResponse])
|
||||||
|
async def list_my_proposed_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""List current user's proposed challenges for a marathon."""
|
||||||
|
# Check marathon exists
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if not marathon:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
# Check user is participant
|
||||||
|
participant = await get_participant(db, current_user.id, marathon_id)
|
||||||
|
if not participant and not current_user.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
|
# Get user's proposed challenges
|
||||||
|
result = await db.execute(
|
||||||
|
select(Challenge)
|
||||||
|
.join(Game, Challenge.game_id == Game.id)
|
||||||
|
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||||
|
.where(
|
||||||
|
Game.marathon_id == marathon_id,
|
||||||
|
Challenge.proposed_by_id == current_user.id,
|
||||||
|
)
|
||||||
|
.order_by(Challenge.created_at.desc())
|
||||||
|
)
|
||||||
|
challenges = result.scalars().all()
|
||||||
|
|
||||||
|
return [build_challenge_response(c, c.game) for c in challenges]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/challenges/{challenge_id}/approve", response_model=ChallengeResponse)
|
||||||
|
async def approve_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Approve a proposed challenge. Organizers only."""
|
||||||
|
challenge = await get_challenge_or_404(db, challenge_id)
|
||||||
|
|
||||||
|
# Check marathon is in preparing state
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id))
|
||||||
|
marathon = result.scalar_one()
|
||||||
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot approve challenges in active or finished marathon")
|
||||||
|
|
||||||
|
# Only organizers can approve
|
||||||
|
await require_organizer(db, current_user, challenge.game.marathon_id)
|
||||||
|
|
||||||
|
if challenge.status != ChallengeStatus.PENDING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Challenge is not pending")
|
||||||
|
|
||||||
|
challenge.status = ChallengeStatus.APPROVED.value
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(challenge)
|
||||||
|
|
||||||
|
# Send Telegram notification to proposer
|
||||||
|
if challenge.proposed_by_id:
|
||||||
|
await telegram_notifier.notify_challenge_approved(
|
||||||
|
db,
|
||||||
|
challenge.proposed_by_id,
|
||||||
|
marathon.title,
|
||||||
|
challenge.game.title,
|
||||||
|
challenge.title
|
||||||
|
)
|
||||||
|
|
||||||
|
return build_challenge_response(challenge, challenge.game)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/challenges/{challenge_id}/reject", response_model=ChallengeResponse)
|
||||||
|
async def reject_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Reject a proposed challenge. Organizers only."""
|
||||||
|
challenge = await get_challenge_or_404(db, challenge_id)
|
||||||
|
|
||||||
|
# Check marathon is in preparing state
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id))
|
||||||
|
marathon = result.scalar_one()
|
||||||
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot reject challenges in active or finished marathon")
|
||||||
|
|
||||||
|
# Only organizers can reject
|
||||||
|
await require_organizer(db, current_user, challenge.game.marathon_id)
|
||||||
|
|
||||||
|
if challenge.status != ChallengeStatus.PENDING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Challenge is not pending")
|
||||||
|
|
||||||
|
# Save info for notification before changing status
|
||||||
|
proposer_id = challenge.proposed_by_id
|
||||||
|
game_title = challenge.game.title
|
||||||
|
challenge_title = challenge.title
|
||||||
|
|
||||||
|
challenge.status = ChallengeStatus.REJECTED.value
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(challenge)
|
||||||
|
|
||||||
|
# Send Telegram notification to proposer
|
||||||
|
if proposer_id:
|
||||||
|
await telegram_notifier.notify_challenge_rejected(
|
||||||
|
db,
|
||||||
|
proposer_id,
|
||||||
|
marathon.title,
|
||||||
|
game_title,
|
||||||
|
challenge_title
|
||||||
|
)
|
||||||
|
|
||||||
|
return build_challenge_response(challenge, challenge.game)
|
||||||
|
|||||||
20
backend/app/api/v1/content.py
Normal file
20
backend/app/api/v1/content.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.deps import DbSession
|
||||||
|
from app.models import StaticContent
|
||||||
|
from app.schemas import StaticContentResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/content", tags=["content"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{key}", response_model=StaticContentResponse)
|
||||||
|
async def get_public_content(key: str, db: DbSession):
|
||||||
|
"""Get public static content by key. No authentication required."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(StaticContent).where(StaticContent.key == key)
|
||||||
|
)
|
||||||
|
content = result.scalar_one_or_none()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=404, detail="Content not found")
|
||||||
|
return content
|
||||||
@@ -86,7 +86,7 @@ async def generate_link_token(current_user: CurrentUser):
|
|||||||
)
|
)
|
||||||
logger.info(f"[TG_LINK] Token created: {token} (length: {len(token)})")
|
logger.info(f"[TG_LINK] Token created: {token} (length: {len(token)})")
|
||||||
|
|
||||||
bot_username = settings.TELEGRAM_BOT_USERNAME or "GameMarathonBot"
|
bot_username = settings.TELEGRAM_BOT_USERNAME or "BCMarathonbot"
|
||||||
bot_url = f"https://t.me/{bot_username}?start={token}"
|
bot_url = f"https://t.me/{bot_username}?start={token}"
|
||||||
logger.info(f"[TG_LINK] Bot URL: {bot_url}")
|
logger.info(f"[TG_LINK] Bot URL: {bot_url}")
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ from app.models.activity import Activity, ActivityType
|
|||||||
from app.models.event import Event, EventType
|
from app.models.event import Event, EventType
|
||||||
from app.models.swap_request import SwapRequest, SwapRequestStatus
|
from app.models.swap_request import SwapRequest, SwapRequestStatus
|
||||||
from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVote
|
from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVote
|
||||||
|
from app.models.admin_log import AdminLog, AdminActionType
|
||||||
|
from app.models.admin_2fa import Admin2FASession
|
||||||
|
from app.models.static_content import StaticContent
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -35,4 +38,8 @@ __all__ = [
|
|||||||
"DisputeStatus",
|
"DisputeStatus",
|
||||||
"DisputeComment",
|
"DisputeComment",
|
||||||
"DisputeVote",
|
"DisputeVote",
|
||||||
|
"AdminLog",
|
||||||
|
"AdminActionType",
|
||||||
|
"Admin2FASession",
|
||||||
|
"StaticContent",
|
||||||
]
|
]
|
||||||
|
|||||||
20
backend/app/models/admin_2fa.py
Normal file
20
backend/app/models/admin_2fa.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, DateTime, Integer, ForeignKey, Boolean
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Admin2FASession(Base):
|
||||||
|
__tablename__ = "admin_2fa_sessions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(6), nullable=False)
|
||||||
|
telegram_sent: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user: Mapped["User"] = relationship("User", foreign_keys=[user_id])
|
||||||
47
backend/app/models/admin_log.py
Normal file
47
backend/app/models/admin_log.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from sqlalchemy import String, DateTime, Integer, ForeignKey, JSON
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AdminActionType(str, Enum):
|
||||||
|
# User actions
|
||||||
|
USER_BAN = "user_ban"
|
||||||
|
USER_UNBAN = "user_unban"
|
||||||
|
USER_AUTO_UNBAN = "user_auto_unban" # System automatic unban
|
||||||
|
USER_ROLE_CHANGE = "user_role_change"
|
||||||
|
USER_PASSWORD_RESET = "user_password_reset"
|
||||||
|
|
||||||
|
# Marathon actions
|
||||||
|
MARATHON_FORCE_FINISH = "marathon_force_finish"
|
||||||
|
MARATHON_DELETE = "marathon_delete"
|
||||||
|
|
||||||
|
# Content actions
|
||||||
|
CONTENT_UPDATE = "content_update"
|
||||||
|
|
||||||
|
# Broadcast actions
|
||||||
|
BROADCAST_ALL = "broadcast_all"
|
||||||
|
BROADCAST_MARATHON = "broadcast_marathon"
|
||||||
|
|
||||||
|
# Auth actions
|
||||||
|
ADMIN_LOGIN = "admin_login"
|
||||||
|
ADMIN_2FA_SUCCESS = "admin_2fa_success"
|
||||||
|
ADMIN_2FA_FAIL = "admin_2fa_fail"
|
||||||
|
|
||||||
|
|
||||||
|
class AdminLog(Base):
|
||||||
|
__tablename__ = "admin_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
admin_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True, index=True) # Nullable for system actions
|
||||||
|
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
target_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
target_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
details: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
ip_address: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
admin: Mapped["User"] = relationship("User", foreign_keys=[admin_id])
|
||||||
@@ -29,6 +29,12 @@ class ProofType(str, Enum):
|
|||||||
STEAM = "steam"
|
STEAM = "steam"
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengeStatus(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
APPROVED = "approved"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
|
||||||
|
|
||||||
class Challenge(Base):
|
class Challenge(Base):
|
||||||
__tablename__ = "challenges"
|
__tablename__ = "challenges"
|
||||||
|
|
||||||
@@ -45,8 +51,13 @@ class Challenge(Base):
|
|||||||
is_generated: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_generated: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Proposed challenges support
|
||||||
|
proposed_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="approved") # pending, approved, rejected
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
game: Mapped["Game"] = relationship("Game", back_populates="challenges")
|
game: Mapped["Game"] = relationship("Game", back_populates="challenges")
|
||||||
|
proposed_by: Mapped["User"] = relationship("User", foreign_keys=[proposed_by_id])
|
||||||
assignments: Mapped[list["Assignment"]] = relationship(
|
assignments: Mapped[list["Assignment"]] = relationship(
|
||||||
"Assignment",
|
"Assignment",
|
||||||
back_populates="challenge"
|
back_populates="challenge"
|
||||||
|
|||||||
20
backend/app/models/static_content.py
Normal file
20
backend/app/models/static_content.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, DateTime, Integer, ForeignKey, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class StaticContent(Base):
|
||||||
|
__tablename__ = "static_content"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
updated_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
updated_by: Mapped["User | None"] = relationship("User", foreign_keys=[updated_by_id])
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from sqlalchemy import String, BigInteger, DateTime
|
from sqlalchemy import String, BigInteger, DateTime, Boolean, ForeignKey, Integer
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -27,6 +27,13 @@ class User(Base):
|
|||||||
role: Mapped[str] = mapped_column(String(20), default=UserRole.USER.value)
|
role: Mapped[str] = mapped_column(String(20), default=UserRole.USER.value)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Ban fields
|
||||||
|
is_banned: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
banned_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
banned_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # None = permanent
|
||||||
|
banned_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
ban_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
created_marathons: Mapped[list["Marathon"]] = relationship(
|
created_marathons: Mapped[list["Marathon"]] = relationship(
|
||||||
"Marathon",
|
"Marathon",
|
||||||
@@ -47,6 +54,11 @@ class User(Base):
|
|||||||
back_populates="approved_by",
|
back_populates="approved_by",
|
||||||
foreign_keys="Game.approved_by_id"
|
foreign_keys="Game.approved_by_id"
|
||||||
)
|
)
|
||||||
|
banned_by: Mapped["User | None"] = relationship(
|
||||||
|
"User",
|
||||||
|
remote_side="User.id",
|
||||||
|
foreign_keys=[banned_by_id]
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_admin(self) -> bool:
|
def is_admin(self) -> bool:
|
||||||
|
|||||||
@@ -81,6 +81,23 @@ from app.schemas.dispute import (
|
|||||||
AssignmentDetailResponse,
|
AssignmentDetailResponse,
|
||||||
ReturnedAssignmentResponse,
|
ReturnedAssignmentResponse,
|
||||||
)
|
)
|
||||||
|
from app.schemas.admin import (
|
||||||
|
BanUserRequest,
|
||||||
|
AdminResetPasswordRequest,
|
||||||
|
AdminUserResponse,
|
||||||
|
AdminLogResponse,
|
||||||
|
AdminLogsListResponse,
|
||||||
|
BroadcastRequest,
|
||||||
|
BroadcastResponse,
|
||||||
|
StaticContentResponse,
|
||||||
|
StaticContentUpdate,
|
||||||
|
StaticContentCreate,
|
||||||
|
TwoFactorInitiateRequest,
|
||||||
|
TwoFactorInitiateResponse,
|
||||||
|
TwoFactorVerifyRequest,
|
||||||
|
LoginResponse,
|
||||||
|
DashboardStats,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# User
|
# User
|
||||||
@@ -157,4 +174,20 @@ __all__ = [
|
|||||||
"DisputeResponse",
|
"DisputeResponse",
|
||||||
"AssignmentDetailResponse",
|
"AssignmentDetailResponse",
|
||||||
"ReturnedAssignmentResponse",
|
"ReturnedAssignmentResponse",
|
||||||
|
# Admin
|
||||||
|
"BanUserRequest",
|
||||||
|
"AdminResetPasswordRequest",
|
||||||
|
"AdminUserResponse",
|
||||||
|
"AdminLogResponse",
|
||||||
|
"AdminLogsListResponse",
|
||||||
|
"BroadcastRequest",
|
||||||
|
"BroadcastResponse",
|
||||||
|
"StaticContentResponse",
|
||||||
|
"StaticContentUpdate",
|
||||||
|
"StaticContentCreate",
|
||||||
|
"TwoFactorInitiateRequest",
|
||||||
|
"TwoFactorInitiateResponse",
|
||||||
|
"TwoFactorVerifyRequest",
|
||||||
|
"LoginResponse",
|
||||||
|
"DashboardStats",
|
||||||
]
|
]
|
||||||
|
|||||||
123
backend/app/schemas/admin.py
Normal file
123
backend/app/schemas/admin.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# ============ User Ban ============
|
||||||
|
class BanUserRequest(BaseModel):
|
||||||
|
reason: str = Field(..., min_length=1, max_length=500)
|
||||||
|
banned_until: datetime | None = None # None = permanent ban
|
||||||
|
|
||||||
|
|
||||||
|
class AdminResetPasswordRequest(BaseModel):
|
||||||
|
new_password: str = Field(..., min_length=6, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
login: str
|
||||||
|
nickname: str
|
||||||
|
role: str
|
||||||
|
avatar_url: str | None = None
|
||||||
|
telegram_id: int | None = None
|
||||||
|
telegram_username: str | None = None
|
||||||
|
marathons_count: int = 0
|
||||||
|
created_at: str
|
||||||
|
is_banned: bool = False
|
||||||
|
banned_at: str | None = None
|
||||||
|
banned_until: str | None = None # None = permanent
|
||||||
|
ban_reason: str | None = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Admin Logs ============
|
||||||
|
class AdminLogResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
admin_id: int | None = None # Nullable for system actions
|
||||||
|
admin_nickname: str | None = None # Nullable for system actions
|
||||||
|
action: str
|
||||||
|
target_type: str
|
||||||
|
target_id: int
|
||||||
|
details: dict | None = None
|
||||||
|
ip_address: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AdminLogsListResponse(BaseModel):
|
||||||
|
logs: list[AdminLogResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Broadcast ============
|
||||||
|
class BroadcastRequest(BaseModel):
|
||||||
|
message: str = Field(..., min_length=1, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class BroadcastResponse(BaseModel):
|
||||||
|
sent_count: int
|
||||||
|
total_count: int
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Static Content ============
|
||||||
|
class StaticContentResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
key: str
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
updated_at: datetime
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class StaticContentUpdate(BaseModel):
|
||||||
|
title: str = Field(..., min_length=1, max_length=200)
|
||||||
|
content: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class StaticContentCreate(BaseModel):
|
||||||
|
key: str = Field(..., min_length=1, max_length=100, pattern=r"^[a-z0-9_-]+$")
|
||||||
|
title: str = Field(..., min_length=1, max_length=200)
|
||||||
|
content: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ 2FA ============
|
||||||
|
class TwoFactorInitiateRequest(BaseModel):
|
||||||
|
pass # No additional data needed
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorInitiateResponse(BaseModel):
|
||||||
|
session_id: int
|
||||||
|
expires_at: datetime
|
||||||
|
message: str = "Code sent to Telegram"
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorVerifyRequest(BaseModel):
|
||||||
|
session_id: int
|
||||||
|
code: str = Field(..., min_length=6, max_length=6)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
"""Login response that may require 2FA"""
|
||||||
|
access_token: str | None = None
|
||||||
|
token_type: str = "bearer"
|
||||||
|
user: Any = None # UserPrivate
|
||||||
|
requires_2fa: bool = False
|
||||||
|
two_factor_session_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Dashboard Stats ============
|
||||||
|
class DashboardStats(BaseModel):
|
||||||
|
users_count: int
|
||||||
|
banned_users_count: int
|
||||||
|
marathons_count: int
|
||||||
|
active_marathons_count: int
|
||||||
|
games_count: int
|
||||||
|
total_participations: int
|
||||||
|
recent_logs: list[AdminLogResponse] = []
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.models.challenge import ChallengeType, Difficulty, ProofType
|
from app.models.challenge import ChallengeType, Difficulty, ProofType, ChallengeStatus
|
||||||
from app.schemas.game import GameShort
|
from app.schemas.game import GameShort
|
||||||
|
|
||||||
|
|
||||||
|
class ProposedByUser(BaseModel):
|
||||||
|
"""Minimal user info for proposed challenges"""
|
||||||
|
id: int
|
||||||
|
nickname: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
class ChallengeBase(BaseModel):
|
class ChallengeBase(BaseModel):
|
||||||
title: str = Field(..., min_length=1, max_length=100)
|
title: str = Field(..., min_length=1, max_length=100)
|
||||||
description: str = Field(..., min_length=1)
|
description: str = Field(..., min_length=1)
|
||||||
@@ -36,11 +45,18 @@ class ChallengeResponse(ChallengeBase):
|
|||||||
game: GameShort
|
game: GameShort
|
||||||
is_generated: bool
|
is_generated: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
status: str = "approved"
|
||||||
|
proposed_by: ProposedByUser | None = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengePropose(ChallengeBase):
|
||||||
|
"""Schema for proposing a challenge by a participant"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ChallengeGenerated(BaseModel):
|
class ChallengeGenerated(BaseModel):
|
||||||
"""Schema for GPT-generated challenges"""
|
"""Schema for GPT-generated challenges"""
|
||||||
title: str
|
title: str
|
||||||
|
|||||||
@@ -276,6 +276,42 @@ class TelegramNotifier:
|
|||||||
)
|
)
|
||||||
return await self.notify_user(db, user_id, message)
|
return await self.notify_user(db, user_id, message)
|
||||||
|
|
||||||
|
async def notify_challenge_approved(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
marathon_title: str,
|
||||||
|
game_title: str,
|
||||||
|
challenge_title: str
|
||||||
|
) -> bool:
|
||||||
|
"""Notify user that their proposed challenge was approved."""
|
||||||
|
message = (
|
||||||
|
f"✅ <b>Твой челлендж одобрен!</b>\n\n"
|
||||||
|
f"Марафон: {marathon_title}\n"
|
||||||
|
f"Игра: {game_title}\n"
|
||||||
|
f"Задание: {challenge_title}\n\n"
|
||||||
|
f"Теперь оно доступно для всех участников."
|
||||||
|
)
|
||||||
|
return await self.notify_user(db, user_id, message)
|
||||||
|
|
||||||
|
async def notify_challenge_rejected(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
marathon_title: str,
|
||||||
|
game_title: str,
|
||||||
|
challenge_title: str
|
||||||
|
) -> bool:
|
||||||
|
"""Notify user that their proposed challenge was rejected."""
|
||||||
|
message = (
|
||||||
|
f"❌ <b>Твой челлендж отклонён</b>\n\n"
|
||||||
|
f"Марафон: {marathon_title}\n"
|
||||||
|
f"Игра: {game_title}\n"
|
||||||
|
f"Задание: {challenge_title}\n\n"
|
||||||
|
f"Ты можешь предложить другой челлендж."
|
||||||
|
)
|
||||||
|
return await self.notify_user(db, user_id, message)
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
# Global instance
|
||||||
telegram_notifier = TelegramNotifier()
|
telegram_notifier = TelegramNotifier()
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ services:
|
|||||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
||||||
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-GameMarathonBot}
|
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-BCMarathonbot}
|
||||||
BOT_API_SECRET: ${BOT_API_SECRET:-}
|
BOT_API_SECRET: ${BOT_API_SECRET:-}
|
||||||
DEBUG: ${DEBUG:-false}
|
DEBUG: ${DEBUG:-false}
|
||||||
# S3 Storage
|
# S3 Storage
|
||||||
|
|||||||
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,6 +1,8 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { ToastContainer, ConfirmModal } from '@/components/ui'
|
import { ToastContainer, ConfirmModal } from '@/components/ui'
|
||||||
|
import { BannedScreen } from '@/components/BannedScreen'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
import { Layout } from '@/components/layout/Layout'
|
import { Layout } from '@/components/layout/Layout'
|
||||||
@@ -19,10 +21,22 @@ import { InvitePage } from '@/pages/InvitePage'
|
|||||||
import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage'
|
import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage'
|
||||||
import { ProfilePage } from '@/pages/ProfilePage'
|
import { ProfilePage } from '@/pages/ProfilePage'
|
||||||
import { UserProfilePage } from '@/pages/UserProfilePage'
|
import { UserProfilePage } from '@/pages/UserProfilePage'
|
||||||
|
import { StaticContentPage } from '@/pages/StaticContentPage'
|
||||||
import { NotFoundPage } from '@/pages/NotFoundPage'
|
import { NotFoundPage } from '@/pages/NotFoundPage'
|
||||||
import { TeapotPage } from '@/pages/TeapotPage'
|
import { TeapotPage } from '@/pages/TeapotPage'
|
||||||
import { ServerErrorPage } from '@/pages/ServerErrorPage'
|
import { ServerErrorPage } from '@/pages/ServerErrorPage'
|
||||||
|
|
||||||
|
// Admin Pages
|
||||||
|
import {
|
||||||
|
AdminLayout,
|
||||||
|
AdminDashboardPage,
|
||||||
|
AdminUsersPage,
|
||||||
|
AdminMarathonsPage,
|
||||||
|
AdminLogsPage,
|
||||||
|
AdminBroadcastPage,
|
||||||
|
AdminContentPage,
|
||||||
|
} from '@/pages/admin'
|
||||||
|
|
||||||
// Protected route wrapper
|
// Protected route wrapper
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
||||||
@@ -46,6 +60,25 @@ function PublicRoute({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const banInfo = useAuthStore((state) => state.banInfo)
|
||||||
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
||||||
|
const syncUser = useAuthStore((state) => state.syncUser)
|
||||||
|
|
||||||
|
// Sync user data with server on app load
|
||||||
|
useEffect(() => {
|
||||||
|
syncUser()
|
||||||
|
}, [syncUser])
|
||||||
|
|
||||||
|
// Show banned screen if user is authenticated and banned
|
||||||
|
if (isAuthenticated && banInfo) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ToastContainer />
|
||||||
|
<BannedScreen banInfo={banInfo} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
@@ -57,6 +90,11 @@ function App() {
|
|||||||
{/* Public invite page */}
|
{/* Public invite page */}
|
||||||
<Route path="invite/:code" element={<InvitePage />} />
|
<Route path="invite/:code" element={<InvitePage />} />
|
||||||
|
|
||||||
|
{/* Public static content pages */}
|
||||||
|
<Route path="terms" element={<StaticContentPage />} />
|
||||||
|
<Route path="privacy" element={<StaticContentPage />} />
|
||||||
|
<Route path="page/:key" element={<StaticContentPage />} />
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="login"
|
path="login"
|
||||||
element={
|
element={
|
||||||
@@ -159,6 +197,23 @@ function App() {
|
|||||||
<Route path="500" element={<ServerErrorPage />} />
|
<Route path="500" element={<ServerErrorPage />} />
|
||||||
<Route path="error" element={<ServerErrorPage />} />
|
<Route path="error" element={<ServerErrorPage />} />
|
||||||
|
|
||||||
|
{/* Admin routes */}
|
||||||
|
<Route
|
||||||
|
path="admin"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AdminLayout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<AdminDashboardPage />} />
|
||||||
|
<Route path="users" element={<AdminUsersPage />} />
|
||||||
|
<Route path="marathons" element={<AdminMarathonsPage />} />
|
||||||
|
<Route path="logs" element={<AdminLogsPage />} />
|
||||||
|
<Route path="broadcast" element={<AdminBroadcastPage />} />
|
||||||
|
<Route path="content" element={<AdminContentPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
{/* 404 - must be last */}
|
{/* 404 - must be last */}
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
import client from './client'
|
import client from './client'
|
||||||
import type { AdminUser, AdminMarathon, UserRole, PlatformStats } from '@/types'
|
import type {
|
||||||
|
AdminUser,
|
||||||
|
AdminMarathon,
|
||||||
|
UserRole,
|
||||||
|
PlatformStats,
|
||||||
|
AdminLogsResponse,
|
||||||
|
BroadcastResponse,
|
||||||
|
StaticContent,
|
||||||
|
DashboardStats
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
|
// Dashboard
|
||||||
|
getDashboard: async (): Promise<DashboardStats> => {
|
||||||
|
const response = await client.get<DashboardStats>('/admin/dashboard')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
listUsers: async (skip = 0, limit = 50, search?: string): Promise<AdminUser[]> => {
|
listUsers: async (skip = 0, limit = 50, search?: string, bannedOnly = false): Promise<AdminUser[]> => {
|
||||||
const params: Record<string, unknown> = { skip, limit }
|
const params: Record<string, unknown> = { skip, limit, banned_only: bannedOnly }
|
||||||
if (search) params.search = search
|
if (search) params.search = search
|
||||||
const response = await client.get<AdminUser[]>('/admin/users', { params })
|
const response = await client.get<AdminUser[]>('/admin/users', { params })
|
||||||
return response.data
|
return response.data
|
||||||
@@ -24,6 +39,26 @@ export const adminApi = {
|
|||||||
await client.delete(`/admin/users/${id}`)
|
await client.delete(`/admin/users/${id}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
banUser: async (id: number, reason: string, bannedUntil?: string): Promise<AdminUser> => {
|
||||||
|
const response = await client.post<AdminUser>(`/admin/users/${id}/ban`, {
|
||||||
|
reason,
|
||||||
|
banned_until: bannedUntil || null,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
unbanUser: async (id: number): Promise<AdminUser> => {
|
||||||
|
const response = await client.post<AdminUser>(`/admin/users/${id}/unban`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
resetUserPassword: async (id: number, newPassword: string): Promise<AdminUser> => {
|
||||||
|
const response = await client.post<AdminUser>(`/admin/users/${id}/reset-password`, {
|
||||||
|
new_password: newPassword,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
// Marathons
|
// Marathons
|
||||||
listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => {
|
listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => {
|
||||||
const params: Record<string, unknown> = { skip, limit }
|
const params: Record<string, unknown> = { skip, limit }
|
||||||
@@ -36,9 +71,66 @@ export const adminApi = {
|
|||||||
await client.delete(`/admin/marathons/${id}`)
|
await client.delete(`/admin/marathons/${id}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
forceFinishMarathon: async (id: number): Promise<void> => {
|
||||||
|
await client.post(`/admin/marathons/${id}/force-finish`)
|
||||||
|
},
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
getStats: async (): Promise<PlatformStats> => {
|
getStats: async (): Promise<PlatformStats> => {
|
||||||
const response = await client.get<PlatformStats>('/admin/stats')
|
const response = await client.get<PlatformStats>('/admin/stats')
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Logs
|
||||||
|
getLogs: async (skip = 0, limit = 50, action?: string, adminId?: number): Promise<AdminLogsResponse> => {
|
||||||
|
const params: Record<string, unknown> = { skip, limit }
|
||||||
|
if (action) params.action = action
|
||||||
|
if (adminId) params.admin_id = adminId
|
||||||
|
const response = await client.get<AdminLogsResponse>('/admin/logs', { params })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Broadcast
|
||||||
|
broadcastToAll: async (message: string): Promise<BroadcastResponse> => {
|
||||||
|
const response = await client.post<BroadcastResponse>('/admin/broadcast/all', { message })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
broadcastToMarathon: async (marathonId: number, message: string): Promise<BroadcastResponse> => {
|
||||||
|
const response = await client.post<BroadcastResponse>(`/admin/broadcast/marathon/${marathonId}`, { message })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Static Content
|
||||||
|
listContent: async (): Promise<StaticContent[]> => {
|
||||||
|
const response = await client.get<StaticContent[]>('/admin/content')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getContent: async (key: string): Promise<StaticContent> => {
|
||||||
|
const response = await client.get<StaticContent>(`/admin/content/${key}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
updateContent: async (key: string, title: string, content: string): Promise<StaticContent> => {
|
||||||
|
const response = await client.put<StaticContent>(`/admin/content/${key}`, { title, content })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
createContent: async (key: string, title: string, content: string): Promise<StaticContent> => {
|
||||||
|
const response = await client.post<StaticContent>('/admin/content', { key, title, content })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteContent: async (key: string): Promise<void> => {
|
||||||
|
await client.delete(`/admin/content/${key}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public content API (no auth required)
|
||||||
|
export const contentApi = {
|
||||||
|
getPublicContent: async (key: string): Promise<StaticContent> => {
|
||||||
|
const response = await client.get<StaticContent>(`/content/${key}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import client from './client'
|
import client from './client'
|
||||||
import type { TokenResponse, User } from '@/types'
|
import type { TokenResponse, LoginResponse, User } from '@/types'
|
||||||
|
|
||||||
export interface RegisterData {
|
export interface RegisterData {
|
||||||
login: string
|
login: string
|
||||||
@@ -18,8 +18,15 @@ export const authApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
login: async (data: LoginData): Promise<TokenResponse> => {
|
login: async (data: LoginData): Promise<LoginResponse> => {
|
||||||
const response = await client.post<TokenResponse>('/auth/login', data)
|
const response = await client.post<LoginResponse>('/auth/login', data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
verify2FA: async (sessionId: number, code: string): Promise<TokenResponse> => {
|
||||||
|
const response = await client.post<TokenResponse>('/auth/2fa/verify', null, {
|
||||||
|
params: { session_id: sessionId, code }
|
||||||
|
})
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios, { AxiosError } from 'axios'
|
import axios, { AxiosError } from 'axios'
|
||||||
|
import { useAuthStore, type BanInfo } from '@/store/auth'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
||||||
|
|
||||||
@@ -18,16 +19,40 @@ client.interceptors.request.use((config) => {
|
|||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Helper to check if detail is ban info object
|
||||||
|
function isBanInfo(detail: unknown): detail is BanInfo {
|
||||||
|
return (
|
||||||
|
typeof detail === 'object' &&
|
||||||
|
detail !== null &&
|
||||||
|
'banned_at' in detail &&
|
||||||
|
'reason' in detail
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Response interceptor to handle errors
|
// Response interceptor to handle errors
|
||||||
client.interceptors.response.use(
|
client.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error: AxiosError<{ detail: string }>) => {
|
(error: AxiosError<{ detail: string | BanInfo }>) => {
|
||||||
// Unauthorized - redirect to login
|
// Unauthorized - redirect to login (but not for auth endpoints)
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
|
const url = error.config?.url || ''
|
||||||
|
const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/register') || url.includes('/auth/2fa')
|
||||||
|
|
||||||
|
if (!isAuthEndpoint) {
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
localStorage.removeItem('user')
|
localStorage.removeItem('user')
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forbidden - check if user is banned
|
||||||
|
if (error.response?.status === 403) {
|
||||||
|
const detail = error.response.data?.detail
|
||||||
|
if (isBanInfo(detail)) {
|
||||||
|
// User is banned - set ban info in store
|
||||||
|
useAuthStore.getState().setBanned(detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Server error or network error - redirect to 500 page
|
// Server error or network error - redirect to 500 page
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ export const gamesApi = {
|
|||||||
await client.delete(`/challenges/${id}`)
|
await client.delete(`/challenges/${id}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateChallenge: async (id: number, data: Partial<CreateChallengeData>): Promise<Challenge> => {
|
||||||
|
const response = await client.patch<Challenge>(`/challenges/${id}`, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
previewChallenges: async (marathonId: number, gameIds?: number[]): Promise<ChallengesPreviewResponse> => {
|
previewChallenges: async (marathonId: number, gameIds?: number[]): Promise<ChallengesPreviewResponse> => {
|
||||||
const data = gameIds?.length ? { game_ids: gameIds } : undefined
|
const data = gameIds?.length ? { game_ids: gameIds } : undefined
|
||||||
const response = await client.post<ChallengesPreviewResponse>(`/marathons/${marathonId}/preview-challenges`, data)
|
const response = await client.post<ChallengesPreviewResponse>(`/marathons/${marathonId}/preview-challenges`, data)
|
||||||
@@ -89,4 +94,30 @@ export const gamesApi = {
|
|||||||
const response = await client.post<{ message: string }>(`/marathons/${marathonId}/save-challenges`, { challenges })
|
const response = await client.post<{ message: string }>(`/marathons/${marathonId}/save-challenges`, { challenges })
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Proposed challenges
|
||||||
|
proposeChallenge: async (gameId: number, data: CreateChallengeData): Promise<Challenge> => {
|
||||||
|
const response = await client.post<Challenge>(`/games/${gameId}/propose-challenge`, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getProposedChallenges: async (marathonId: number): Promise<Challenge[]> => {
|
||||||
|
const response = await client.get<Challenge[]>(`/marathons/${marathonId}/proposed-challenges`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getMyProposedChallenges: async (marathonId: number): Promise<Challenge[]> => {
|
||||||
|
const response = await client.get<Challenge[]>(`/marathons/${marathonId}/my-proposed-challenges`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
approveChallenge: async (id: number): Promise<Challenge> => {
|
||||||
|
const response = await client.patch<Challenge>(`/challenges/${id}/approve`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
rejectChallenge: async (id: number): Promise<Challenge> => {
|
||||||
|
const response = await client.patch<Challenge>(`/challenges/${id}/reject`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export { marathonsApi } from './marathons'
|
|||||||
export { gamesApi } from './games'
|
export { gamesApi } from './games'
|
||||||
export { wheelApi } from './wheel'
|
export { wheelApi } from './wheel'
|
||||||
export { feedApi } from './feed'
|
export { feedApi } from './feed'
|
||||||
export { adminApi } from './admin'
|
export { adminApi, contentApi } from './admin'
|
||||||
export { eventsApi } from './events'
|
export { eventsApi } from './events'
|
||||||
export { challengesApi } from './challenges'
|
export { challengesApi } from './challenges'
|
||||||
export { assignmentsApi } from './assignments'
|
export { assignmentsApi } from './assignments'
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
130
frontend/src/components/BannedScreen.tsx
Normal file
130
frontend/src/components/BannedScreen.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { Ban, LogOut, Calendar, Clock, AlertTriangle, Sparkles } from 'lucide-react'
|
||||||
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { NeonButton } from '@/components/ui'
|
||||||
|
|
||||||
|
interface BanInfo {
|
||||||
|
banned_at: string | null
|
||||||
|
banned_until: string | null
|
||||||
|
reason: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BannedScreenProps {
|
||||||
|
banInfo: BanInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null) {
|
||||||
|
if (!dateStr) return null
|
||||||
|
return new Date(dateStr).toLocaleString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: 'Europe/Moscow',
|
||||||
|
}) + ' (МСК)'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BannedScreen({ banInfo }: BannedScreenProps) {
|
||||||
|
const logout = useAuthStore((state) => state.logout)
|
||||||
|
|
||||||
|
const bannedAtFormatted = formatDate(banInfo.banned_at)
|
||||||
|
const bannedUntilFormatted = formatDate(banInfo.banned_until)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-dark-900 flex flex-col items-center justify-center text-center px-4">
|
||||||
|
{/* Background effects */}
|
||||||
|
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<div className="absolute top-1/4 -left-32 w-96 h-96 bg-red-500/5 rounded-full blur-[100px]" />
|
||||||
|
<div className="absolute bottom-1/4 -right-32 w-96 h-96 bg-orange-500/5 rounded-full blur-[100px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="relative mb-8">
|
||||||
|
<div className="w-32 h-32 rounded-3xl bg-dark-700/50 border-2 border-red-500/30 flex items-center justify-center shadow-[0_0_30px_rgba(239,68,68,0.2)]">
|
||||||
|
<Ban className="w-16 h-16 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute -bottom-2 -right-2 w-12 h-12 rounded-xl bg-red-500/20 border border-red-500/40 flex items-center justify-center animate-pulse">
|
||||||
|
<AlertTriangle className="w-6 h-6 text-red-400" />
|
||||||
|
</div>
|
||||||
|
{/* Decorative dots */}
|
||||||
|
<div className="absolute -top-2 -left-2 w-3 h-3 rounded-full bg-red-500/50 animate-pulse" />
|
||||||
|
<div className="absolute top-4 -right-4 w-2 h-2 rounded-full bg-orange-500/50 animate-pulse" style={{ animationDelay: '0.5s' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title with glow */}
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<h1 className="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-red-400 via-orange-400 to-red-400">
|
||||||
|
Аккаунт заблокирован
|
||||||
|
</h1>
|
||||||
|
<div className="absolute inset-0 text-4xl font-bold text-red-500/20 blur-xl">
|
||||||
|
Аккаунт заблокирован
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-400 mb-8 max-w-md">
|
||||||
|
Ваш доступ к платформе был ограничен администрацией.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Ban Info Card */}
|
||||||
|
<div className="glass rounded-2xl p-6 mb-8 max-w-md w-full border border-red-500/20 text-left space-y-4">
|
||||||
|
{bannedAtFormatted && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-dark-700/50">
|
||||||
|
<Calendar className="w-5 h-5 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wider">Дата блокировки</p>
|
||||||
|
<p className="text-white font-medium">{bannedAtFormatted}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-dark-700/50">
|
||||||
|
<Clock className="w-5 h-5 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wider">Срок</p>
|
||||||
|
<p className={`font-medium ${bannedUntilFormatted ? 'text-amber-400' : 'text-red-400'}`}>
|
||||||
|
{bannedUntilFormatted ? `до ${bannedUntilFormatted}` : 'Навсегда'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{banInfo.reason && (
|
||||||
|
<div className="pt-4 border-t border-dark-600">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Причина</p>
|
||||||
|
<p className="text-white bg-dark-700/50 rounded-xl p-4 border border-dark-600">
|
||||||
|
{banInfo.reason}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info text */}
|
||||||
|
<p className="text-gray-500 text-sm mb-8 max-w-md">
|
||||||
|
{banInfo.banned_until
|
||||||
|
? 'Ваш аккаунт будет автоматически разблокирован по истечении срока.'
|
||||||
|
: 'Если вы считаете, что блокировка ошибочна, обратитесь к администрации.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Logout button */}
|
||||||
|
<NeonButton
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
onClick={logout}
|
||||||
|
icon={<LogOut className="w-5 h-5" />}
|
||||||
|
>
|
||||||
|
Выйти из аккаунта
|
||||||
|
</NeonButton>
|
||||||
|
|
||||||
|
{/* Decorative sparkles */}
|
||||||
|
<div className="absolute top-1/4 left-1/4 opacity-20">
|
||||||
|
<Sparkles className="w-6 h-6 text-red-400 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-1/3 right-1/4 opacity-20">
|
||||||
|
<Sparkles className="w-4 h-4 text-orange-400 animate-pulse" style={{ animationDelay: '1s' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
|
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { Gamepad2, LogOut, Trophy, User, Menu, X } from 'lucide-react'
|
import { Gamepad2, LogOut, Trophy, User, Menu, X, Shield } from 'lucide-react'
|
||||||
import { TelegramLink } from '@/components/TelegramLink'
|
import { TelegramLink } from '@/components/TelegramLink'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
|
|
||||||
@@ -74,6 +74,21 @@ export function Layout() {
|
|||||||
<span>Марафоны</span>
|
<span>Марафоны</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200',
|
||||||
|
location.pathname.startsWith('/admin')
|
||||||
|
? 'text-purple-400 bg-purple-500/10'
|
||||||
|
: 'text-gray-300 hover:text-white hover:bg-dark-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Shield className="w-5 h-5" />
|
||||||
|
<span>Админка</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-3 ml-2 pl-4 border-l border-dark-600">
|
<div className="flex items-center gap-3 ml-2 pl-4 border-l border-dark-600">
|
||||||
<Link
|
<Link
|
||||||
to="/profile"
|
to="/profile"
|
||||||
@@ -144,6 +159,20 @@ export function Layout() {
|
|||||||
<Trophy className="w-5 h-5" />
|
<Trophy className="w-5 h-5" />
|
||||||
<span>Марафоны</span>
|
<span>Марафоны</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
|
||||||
|
location.pathname.startsWith('/admin')
|
||||||
|
? 'text-purple-400 bg-purple-500/10'
|
||||||
|
: 'text-gray-300 hover:text-white hover:bg-dark-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Shield className="w-5 h-5" />
|
||||||
|
<span>Админка</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
to="/profile"
|
to="/profile"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -205,7 +234,13 @@ export function Layout() {
|
|||||||
Игровой Марафон © {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>
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ 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
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
export function LobbyPage() {
|
export function LobbyPage() {
|
||||||
@@ -61,9 +62,34 @@ export function LobbyPage() {
|
|||||||
})
|
})
|
||||||
const [isCreatingChallenge, setIsCreatingChallenge] = useState(false)
|
const [isCreatingChallenge, setIsCreatingChallenge] = useState(false)
|
||||||
|
|
||||||
|
// Edit challenge
|
||||||
|
const [editingChallengeId, setEditingChallengeId] = useState<number | null>(null)
|
||||||
|
const [editChallenge, setEditChallenge] = useState({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
type: 'completion',
|
||||||
|
difficulty: 'medium',
|
||||||
|
points: 50,
|
||||||
|
estimated_time: 30,
|
||||||
|
proof_type: 'screenshot',
|
||||||
|
proof_hint: '',
|
||||||
|
})
|
||||||
|
const [isUpdatingChallenge, setIsUpdatingChallenge] = useState(false)
|
||||||
|
|
||||||
|
// Proposed challenges
|
||||||
|
const [proposedChallenges, setProposedChallenges] = useState<Challenge[]>([])
|
||||||
|
const [myProposedChallenges, setMyProposedChallenges] = useState<Challenge[]>([])
|
||||||
|
const [approvingChallengeId, setApprovingChallengeId] = useState<number | null>(null)
|
||||||
|
const [isProposingChallenge, setIsProposingChallenge] = useState(false)
|
||||||
|
const [editingProposedId, setEditingProposedId] = useState<number | null>(null)
|
||||||
|
|
||||||
// Start marathon
|
// Start marathon
|
||||||
const [isStarting, setIsStarting] = useState(false)
|
const [isStarting, setIsStarting] = useState(false)
|
||||||
|
|
||||||
|
// Search
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [generateSearchQuery, setGenerateSearchQuery] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
}, [id])
|
}, [id])
|
||||||
@@ -84,6 +110,23 @@ export function LobbyPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
setPendingGames([])
|
setPendingGames([])
|
||||||
}
|
}
|
||||||
|
// Load proposed challenges for organizers
|
||||||
|
try {
|
||||||
|
const proposed = await gamesApi.getProposedChallenges(parseInt(id))
|
||||||
|
setProposedChallenges(proposed)
|
||||||
|
} catch {
|
||||||
|
setProposedChallenges([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load my proposed challenges for all participants
|
||||||
|
if (marathonData.my_participation) {
|
||||||
|
try {
|
||||||
|
const myProposed = await gamesApi.getMyProposedChallenges(parseInt(id))
|
||||||
|
setMyProposedChallenges(myProposed)
|
||||||
|
} catch {
|
||||||
|
setMyProposedChallenges([])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load data:', error)
|
console.error('Failed to load data:', error)
|
||||||
@@ -249,6 +292,206 @@ export function LobbyPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleStartEditChallenge = (challenge: Challenge) => {
|
||||||
|
setEditingChallengeId(challenge.id)
|
||||||
|
setEditChallenge({
|
||||||
|
title: challenge.title,
|
||||||
|
description: challenge.description,
|
||||||
|
type: challenge.type,
|
||||||
|
difficulty: challenge.difficulty,
|
||||||
|
points: challenge.points,
|
||||||
|
estimated_time: challenge.estimated_time || 30,
|
||||||
|
proof_type: challenge.proof_type,
|
||||||
|
proof_hint: challenge.proof_hint || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateChallenge = async (challengeId: number, gameId: number) => {
|
||||||
|
if (!editChallenge.title.trim() || !editChallenge.description.trim()) {
|
||||||
|
toast.warning('Заполните название и описание')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdatingChallenge(true)
|
||||||
|
try {
|
||||||
|
await gamesApi.updateChallenge(challengeId, {
|
||||||
|
title: editChallenge.title.trim(),
|
||||||
|
description: editChallenge.description.trim(),
|
||||||
|
type: editChallenge.type,
|
||||||
|
difficulty: editChallenge.difficulty,
|
||||||
|
points: editChallenge.points,
|
||||||
|
estimated_time: editChallenge.estimated_time || undefined,
|
||||||
|
proof_type: editChallenge.proof_type,
|
||||||
|
proof_hint: editChallenge.proof_hint.trim() || undefined,
|
||||||
|
})
|
||||||
|
toast.success('Задание обновлено')
|
||||||
|
setEditingChallengeId(null)
|
||||||
|
const challenges = await gamesApi.getChallenges(gameId)
|
||||||
|
setGameChallenges(prev => ({ ...prev, [gameId]: challenges }))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось обновить задание')
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingChallenge(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadProposedChallenges = async () => {
|
||||||
|
if (!id) return
|
||||||
|
try {
|
||||||
|
const proposed = await gamesApi.getProposedChallenges(parseInt(id))
|
||||||
|
setProposedChallenges(proposed)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load proposed challenges:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApproveChallenge = async (challengeId: number) => {
|
||||||
|
setApprovingChallengeId(challengeId)
|
||||||
|
try {
|
||||||
|
await gamesApi.approveChallenge(challengeId)
|
||||||
|
toast.success('Задание одобрено')
|
||||||
|
await loadProposedChallenges()
|
||||||
|
// Reload challenges for the game
|
||||||
|
const challenge = proposedChallenges.find(c => c.id === challengeId)
|
||||||
|
if (challenge) {
|
||||||
|
const challenges = await gamesApi.getChallenges(challenge.game.id)
|
||||||
|
setGameChallenges(prev => ({ ...prev, [challenge.game.id]: challenges }))
|
||||||
|
}
|
||||||
|
await loadData()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось одобрить задание')
|
||||||
|
} finally {
|
||||||
|
setApprovingChallengeId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRejectChallenge = async (challengeId: number) => {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Отклонить задание?',
|
||||||
|
message: 'Задание будет удалено.',
|
||||||
|
confirmText: 'Отклонить',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'danger',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setApprovingChallengeId(challengeId)
|
||||||
|
try {
|
||||||
|
await gamesApi.rejectChallenge(challengeId)
|
||||||
|
toast.success('Задание отклонено')
|
||||||
|
await loadProposedChallenges()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось отклонить задание')
|
||||||
|
} finally {
|
||||||
|
setApprovingChallengeId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStartEditProposed = (challenge: Challenge) => {
|
||||||
|
setEditingProposedId(challenge.id)
|
||||||
|
setEditChallenge({
|
||||||
|
title: challenge.title,
|
||||||
|
description: challenge.description,
|
||||||
|
type: challenge.type,
|
||||||
|
difficulty: challenge.difficulty,
|
||||||
|
points: challenge.points,
|
||||||
|
estimated_time: challenge.estimated_time || 30,
|
||||||
|
proof_type: challenge.proof_type,
|
||||||
|
proof_hint: challenge.proof_hint || '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateProposedChallenge = async (challengeId: number) => {
|
||||||
|
if (!editChallenge.title.trim() || !editChallenge.description.trim()) {
|
||||||
|
toast.warning('Заполните название и описание')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdatingChallenge(true)
|
||||||
|
try {
|
||||||
|
await gamesApi.updateChallenge(challengeId, {
|
||||||
|
title: editChallenge.title.trim(),
|
||||||
|
description: editChallenge.description.trim(),
|
||||||
|
type: editChallenge.type,
|
||||||
|
difficulty: editChallenge.difficulty,
|
||||||
|
points: editChallenge.points,
|
||||||
|
estimated_time: editChallenge.estimated_time || undefined,
|
||||||
|
proof_type: editChallenge.proof_type,
|
||||||
|
proof_hint: editChallenge.proof_hint.trim() || undefined,
|
||||||
|
})
|
||||||
|
toast.success('Задание обновлено')
|
||||||
|
setEditingProposedId(null)
|
||||||
|
await loadProposedChallenges()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось обновить задание')
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingChallenge(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleProposeChallenge = async (gameId: number) => {
|
||||||
|
if (!newChallenge.title.trim() || !newChallenge.description.trim()) {
|
||||||
|
toast.warning('Заполните название и описание')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsProposingChallenge(true)
|
||||||
|
try {
|
||||||
|
await gamesApi.proposeChallenge(gameId, {
|
||||||
|
title: newChallenge.title.trim(),
|
||||||
|
description: newChallenge.description.trim(),
|
||||||
|
type: newChallenge.type,
|
||||||
|
difficulty: newChallenge.difficulty,
|
||||||
|
points: newChallenge.points,
|
||||||
|
estimated_time: newChallenge.estimated_time || undefined,
|
||||||
|
proof_type: newChallenge.proof_type,
|
||||||
|
proof_hint: newChallenge.proof_hint.trim() || undefined,
|
||||||
|
})
|
||||||
|
toast.success('Задание предложено на модерацию')
|
||||||
|
setNewChallenge({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
type: 'completion',
|
||||||
|
difficulty: 'medium',
|
||||||
|
points: 50,
|
||||||
|
estimated_time: 30,
|
||||||
|
proof_type: 'screenshot',
|
||||||
|
proof_hint: '',
|
||||||
|
})
|
||||||
|
setAddingChallengeToGameId(null)
|
||||||
|
await loadData()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось предложить задание')
|
||||||
|
} finally {
|
||||||
|
setIsProposingChallenge(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteMyProposedChallenge = async (challengeId: number) => {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Удалить предложение?',
|
||||||
|
message: 'Предложенное задание будет удалено.',
|
||||||
|
confirmText: 'Удалить',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'danger',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gamesApi.deleteChallenge(challengeId)
|
||||||
|
toast.success('Предложение удалено')
|
||||||
|
await loadData()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
toast.error(error.response?.data?.detail || 'Не удалось удалить предложение')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleGenerateChallenges = async () => {
|
const handleGenerateChallenges = async () => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
|
||||||
@@ -263,6 +506,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)
|
||||||
@@ -476,14 +720,122 @@ export function LobbyPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{gameChallenges[game.id]?.length > 0 ? (
|
{(() => {
|
||||||
gameChallenges[game.id].map((challenge) => (
|
// For organizers: hide pending challenges (they see them in separate block)
|
||||||
|
// For regular users: hide their own pending/rejected challenges (they see them in "My proposals")
|
||||||
|
// but show their own approved challenges in both places
|
||||||
|
const visibleChallenges = isOrganizer
|
||||||
|
? gameChallenges[game.id]?.filter(c => c.status !== 'pending') || []
|
||||||
|
: gameChallenges[game.id]?.filter(c =>
|
||||||
|
!(c.proposed_by?.id === user?.id && c.status !== 'approved')
|
||||||
|
) || []
|
||||||
|
|
||||||
|
return visibleChallenges.length > 0 ? (
|
||||||
|
visibleChallenges.map((challenge) => (
|
||||||
<div
|
<div
|
||||||
key={challenge.id}
|
key={challenge.id}
|
||||||
className="flex items-start justify-between gap-3 p-3 bg-dark-700/50 rounded-lg border border-dark-600"
|
className="p-3 bg-dark-700/50 rounded-lg border border-dark-600"
|
||||||
>
|
>
|
||||||
|
{editingChallengeId === challenge.id ? (
|
||||||
|
// Edit form
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input
|
||||||
|
placeholder="Название задания"
|
||||||
|
value={editChallenge.title}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, title: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
placeholder="Описание"
|
||||||
|
value={editChallenge.description}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, description: 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>
|
||||||
|
<select
|
||||||
|
value={editChallenge.type}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, type: e.target.value }))}
|
||||||
|
className="input w-full"
|
||||||
|
>
|
||||||
|
<option value="completion">Прохождение</option>
|
||||||
|
<option value="no_death">Без смертей</option>
|
||||||
|
<option value="speedrun">Спидран</option>
|
||||||
|
<option value="collection">Коллекция</option>
|
||||||
|
<option value="achievement">Достижение</option>
|
||||||
|
<option value="challenge_run">Челлендж-ран</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Сложность</label>
|
||||||
|
<select
|
||||||
|
value={editChallenge.difficulty}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, difficulty: e.target.value }))}
|
||||||
|
className="input w-full"
|
||||||
|
>
|
||||||
|
<option value="easy">Легко</option>
|
||||||
|
<option value="medium">Средне</option>
|
||||||
|
<option value="hard">Сложно</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Очки</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editChallenge.points}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
|
||||||
|
min={1}
|
||||||
|
max={500}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Тип пруфа</label>
|
||||||
|
<select
|
||||||
|
value={editChallenge.proof_type}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, proof_type: e.target.value }))}
|
||||||
|
className="input w-full"
|
||||||
|
>
|
||||||
|
<option value="screenshot">Скриншот</option>
|
||||||
|
<option value="video">Видео</option>
|
||||||
|
<option value="steam">Steam</option>
|
||||||
|
</select>
|
||||||
|
</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">
|
||||||
|
<NeonButton
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleUpdateChallenge(challenge.id, game.id)}
|
||||||
|
isLoading={isUpdatingChallenge}
|
||||||
|
icon={<Check className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</NeonButton>
|
||||||
|
<NeonButton
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditingChallengeId(null)}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Display challenge
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
|
{challenge.status === 'pending' && getStatusBadge('pending')}
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
|
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
|
||||||
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
|
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
|
||||||
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
|
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
|
||||||
@@ -500,17 +852,35 @@ export function LobbyPage() {
|
|||||||
<Sparkles className="w-3 h-3" /> ИИ
|
<Sparkles className="w-3 h-3" /> ИИ
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{challenge.proposed_by && (
|
||||||
|
<span className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
|
<User className="w-3 h-3" /> {challenge.proposed_by.nickname}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</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">
|
||||||
|
<button
|
||||||
|
onClick={() => handleStartEditChallenge(challenge)}
|
||||||
|
className="p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteChallenge(challenge.id, game.id)}
|
onClick={() => handleDeleteChallenge(challenge.id, game.id)}
|
||||||
className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors shrink-0"
|
className="p-1.5 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -518,15 +888,16 @@ export function LobbyPage() {
|
|||||||
<p className="text-center text-gray-500 py-4 text-sm">
|
<p className="text-center text-gray-500 py-4 text-sm">
|
||||||
Нет заданий
|
Нет заданий
|
||||||
</p>
|
</p>
|
||||||
)}
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Add challenge form */}
|
{/* Add/Propose challenge form */}
|
||||||
{isOrganizer && game.status === 'approved' && (
|
{game.status === 'approved' && (
|
||||||
addingChallengeToGameId === game.id ? (
|
addingChallengeToGameId === game.id ? (
|
||||||
<div className="mt-4 p-4 glass rounded-xl space-y-3 border border-neon-500/20">
|
<div className="mt-4 p-4 glass rounded-xl space-y-3 border border-neon-500/20">
|
||||||
<h4 className="font-semibold text-white text-sm flex items-center gap-2">
|
<h4 className="font-semibold text-white text-sm flex items-center gap-2">
|
||||||
<Plus className="w-4 h-4 text-neon-400" />
|
<Plus className="w-4 h-4 text-neon-400" />
|
||||||
Новое задание
|
{isOrganizer ? 'Новое задание' : 'Предложить задание'}
|
||||||
</h4>
|
</h4>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Название задания"
|
placeholder="Название задания"
|
||||||
@@ -613,6 +984,7 @@ export function LobbyPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
{isOrganizer ? (
|
||||||
<NeonButton
|
<NeonButton
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleCreateChallenge(game.id)}
|
onClick={() => handleCreateChallenge(game.id)}
|
||||||
@@ -622,6 +994,17 @@ export function LobbyPage() {
|
|||||||
>
|
>
|
||||||
Добавить
|
Добавить
|
||||||
</NeonButton>
|
</NeonButton>
|
||||||
|
) : (
|
||||||
|
<NeonButton
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleProposeChallenge(game.id)}
|
||||||
|
isLoading={isProposingChallenge}
|
||||||
|
disabled={!newChallenge.title || !newChallenge.description}
|
||||||
|
icon={<Plus className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Предложить
|
||||||
|
</NeonButton>
|
||||||
|
)}
|
||||||
<NeonButton
|
<NeonButton
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -630,6 +1013,11 @@ export function LobbyPage() {
|
|||||||
Отмена
|
Отмена
|
||||||
</NeonButton>
|
</NeonButton>
|
||||||
</div>
|
</div>
|
||||||
|
{!isOrganizer && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Задание будет отправлено на модерацию организаторам
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -640,7 +1028,7 @@ export function LobbyPage() {
|
|||||||
className="w-full mt-2 p-3 rounded-lg border-2 border-dashed border-dark-600 text-gray-400 hover:text-neon-400 hover:border-neon-500/30 transition-all flex items-center justify-center gap-2"
|
className="w-full mt-2 p-3 rounded-lg border-2 border-dashed border-dark-600 text-gray-400 hover:text-neon-400 hover:border-neon-500/30 transition-all flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Добавить задание вручную
|
{isOrganizer ? 'Добавить задание вручную' : 'Предложить задание'}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -721,6 +1109,247 @@ export function LobbyPage() {
|
|||||||
</GlassCard>
|
</GlassCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Proposed challenges for moderation */}
|
||||||
|
{isOrganizer && proposedChallenges.length > 0 && (
|
||||||
|
<GlassCard className="mb-8 border-accent-500/30">
|
||||||
|
<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">
|
||||||
|
<Sparkles className="w-5 h-5 text-accent-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-accent-400">Предложенные задания</h3>
|
||||||
|
<p className="text-sm text-gray-400">{proposedChallenges.length} заданий ожидают</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{proposedChallenges.map((challenge) => (
|
||||||
|
<div
|
||||||
|
key={challenge.id}
|
||||||
|
className="p-4 bg-dark-700/50 rounded-xl border border-dark-600"
|
||||||
|
>
|
||||||
|
{editingProposedId === challenge.id ? (
|
||||||
|
// Edit form
|
||||||
|
<div className="space-y-3">
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-lg bg-dark-600 text-gray-400">
|
||||||
|
{challenge.game.title}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
placeholder="Название задания"
|
||||||
|
value={editChallenge.title}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, title: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
placeholder="Описание"
|
||||||
|
value={editChallenge.description}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, description: 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>
|
||||||
|
<select
|
||||||
|
value={editChallenge.type}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, type: e.target.value }))}
|
||||||
|
className="input w-full"
|
||||||
|
>
|
||||||
|
<option value="completion">Прохождение</option>
|
||||||
|
<option value="no_death">Без смертей</option>
|
||||||
|
<option value="speedrun">Спидран</option>
|
||||||
|
<option value="collection">Коллекция</option>
|
||||||
|
<option value="achievement">Достижение</option>
|
||||||
|
<option value="challenge_run">Челлендж-ран</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Сложность</label>
|
||||||
|
<select
|
||||||
|
value={editChallenge.difficulty}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, difficulty: e.target.value }))}
|
||||||
|
className="input w-full"
|
||||||
|
>
|
||||||
|
<option value="easy">Легко</option>
|
||||||
|
<option value="medium">Средне</option>
|
||||||
|
<option value="hard">Сложно</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Очки</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editChallenge.points}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, points: parseInt(e.target.value) || 0 }))}
|
||||||
|
min={1}
|
||||||
|
max={500}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">Тип пруфа</label>
|
||||||
|
<select
|
||||||
|
value={editChallenge.proof_type}
|
||||||
|
onChange={(e) => setEditChallenge(prev => ({ ...prev, proof_type: e.target.value }))}
|
||||||
|
className="input w-full"
|
||||||
|
>
|
||||||
|
<option value="screenshot">Скриншот</option>
|
||||||
|
<option value="video">Видео</option>
|
||||||
|
<option value="steam">Steam</option>
|
||||||
|
</select>
|
||||||
|
</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">
|
||||||
|
<NeonButton
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleUpdateProposedChallenge(challenge.id)}
|
||||||
|
isLoading={isUpdatingChallenge}
|
||||||
|
icon={<Check className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</NeonButton>
|
||||||
|
<NeonButton
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditingProposedId(null)}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
{challenge.proposed_by && (
|
||||||
|
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
|
<User className="w-3 h-3" /> Предложил: {challenge.proposed_by.nickname}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Display
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-lg bg-dark-600 text-gray-400">
|
||||||
|
{challenge.game.title}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
|
||||||
|
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
|
||||||
|
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
|
||||||
|
'bg-red-500/20 text-red-400 border-red-500/30'
|
||||||
|
}`}>
|
||||||
|
{challenge.difficulty === 'easy' ? 'Легко' :
|
||||||
|
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-neon-400 font-semibold">
|
||||||
|
+{challenge.points}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h4 className="font-medium text-white mb-1">{challenge.title}</h4>
|
||||||
|
<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 && (
|
||||||
|
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||||
|
<User className="w-3 h-3" /> Предложил: {challenge.proposed_by.nickname}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => handleStartEditProposed(challenge)}
|
||||||
|
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-dark-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleApproveChallenge(challenge.id)}
|
||||||
|
disabled={approvingChallengeId === challenge.id}
|
||||||
|
className="p-2 rounded-lg text-green-400 hover:bg-green-500/10 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{approvingChallengeId === challenge.id ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRejectChallenge(challenge.id)}
|
||||||
|
disabled={approvingChallengeId === challenge.id}
|
||||||
|
className="p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* My proposed challenges (for non-organizers) */}
|
||||||
|
{!isOrganizer && myProposedChallenges.length > 0 && (
|
||||||
|
<GlassCard className="mb-8 border-neon-500/30">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-neon-500/20 flex items-center justify-center">
|
||||||
|
<Sparkles className="w-5 h-5 text-neon-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-neon-400">Мои предложения</h3>
|
||||||
|
<p className="text-sm text-gray-400">{myProposedChallenges.length} заданий</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{myProposedChallenges.map((challenge) => (
|
||||||
|
<div
|
||||||
|
key={challenge.id}
|
||||||
|
className="flex items-start justify-between gap-3 p-4 bg-dark-700/50 rounded-xl border border-dark-600"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-lg bg-dark-600 text-gray-400">
|
||||||
|
{challenge.game.title}
|
||||||
|
</span>
|
||||||
|
{getStatusBadge(challenge.status)}
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-lg border ${
|
||||||
|
challenge.difficulty === 'easy' ? 'bg-green-500/20 text-green-400 border-green-500/30' :
|
||||||
|
challenge.difficulty === 'medium' ? 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' :
|
||||||
|
'bg-red-500/20 text-red-400 border-red-500/30'
|
||||||
|
}`}>
|
||||||
|
{challenge.difficulty === 'easy' ? 'Легко' :
|
||||||
|
challenge.difficulty === 'medium' ? 'Средне' : 'Сложно'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-neon-400 font-semibold">
|
||||||
|
+{challenge.points}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h4 className="font-medium text-white mb-1">{challenge.title}</h4>
|
||||||
|
<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>
|
||||||
|
{challenge.status === 'pending' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteMyProposedChallenge(challenge.id)}
|
||||||
|
className="p-2 rounded-lg text-red-400 hover:bg-red-500/10 transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Generate challenges */}
|
{/* Generate challenges */}
|
||||||
{isOrganizer && approvedGames.length > 0 && !previewChallenges && (
|
{isOrganizer && approvedGames.length > 0 && !previewChallenges && (
|
||||||
<GlassCard className="mb-8">
|
<GlassCard className="mb-8">
|
||||||
@@ -745,6 +1374,7 @@ export function LobbyPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowGenerateSelection(false)
|
setShowGenerateSelection(false)
|
||||||
clearGameSelection()
|
clearGameSelection()
|
||||||
|
setGenerateSearchQuery('')
|
||||||
}}
|
}}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -778,6 +1408,25 @@ export function LobbyPage() {
|
|||||||
{/* Game selection */}
|
{/* Game selection */}
|
||||||
{showGenerateSelection && (
|
{showGenerateSelection && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{/* Search in generation */}
|
||||||
|
<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={generateSearchQuery}
|
||||||
|
onChange={(e) => setGenerateSearchQuery(e.target.value)}
|
||||||
|
className="input w-full pl-10 pr-10 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
{generateSearchQuery && (
|
||||||
|
<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 className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<button
|
<button
|
||||||
onClick={selectAllGamesForGeneration}
|
onClick={selectAllGamesForGeneration}
|
||||||
@@ -792,8 +1441,18 @@ export function LobbyPage() {
|
|||||||
Снять выбор
|
Снять выбор
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2 max-h-64 overflow-y-auto custom-scrollbar">
|
||||||
{approvedGames.map((game) => {
|
{(() => {
|
||||||
|
const filteredGames = generateSearchQuery
|
||||||
|
? fuzzyFilter(approvedGames, generateSearchQuery, (g) => g.title + ' ' + (g.genre || ''))
|
||||||
|
: approvedGames
|
||||||
|
|
||||||
|
return filteredGames.length === 0 ? (
|
||||||
|
<p className="text-center text-gray-500 py-4 text-sm">
|
||||||
|
Ничего не найдено по запросу "{generateSearchQuery}"
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
filteredGames.map((game) => {
|
||||||
const isSelected = selectedGamesForGeneration.includes(game.id)
|
const isSelected = selectedGamesForGeneration.includes(game.id)
|
||||||
const challengeCount = gameChallenges[game.id]?.length ?? game.challenges_count
|
const challengeCount = gameChallenges[game.id]?.length ?? game.challenges_count
|
||||||
return (
|
return (
|
||||||
@@ -821,7 +1480,9 @@ export function LobbyPage() {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -976,7 +1637,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" />
|
||||||
@@ -994,6 +1655,26 @@ export function LobbyPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative mb-6">
|
||||||
|
<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>
|
||||||
|
|
||||||
{/* 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">
|
||||||
@@ -1034,17 +1715,29 @@ export function LobbyPage() {
|
|||||||
|
|
||||||
{/* Games */}
|
{/* Games */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const visibleGames = isOrganizer
|
const baseGames = 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))
|
||||||
|
|
||||||
|
const visibleGames = searchQuery
|
||||||
|
? fuzzyFilter(baseGames, searchQuery, (g) => g.title + ' ' + (g.genre || ''))
|
||||||
|
: baseGames
|
||||||
|
|
||||||
return visibleGames.length === 0 ? (
|
return visibleGames.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">
|
||||||
|
{searchQuery ? (
|
||||||
|
<Search className="w-8 h-8 text-gray-600" />
|
||||||
|
) : (
|
||||||
<Gamepad2 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 ? 'Пока нет игр. Добавьте игры, чтобы начать!' : 'Пока нет одобренных игр. Предложите свою!'}
|
{searchQuery
|
||||||
|
? `Ничего не найдено по запросу "${searchQuery}"`
|
||||||
|
: isOrganizer
|
||||||
|
? 'Пока нет игр. Добавьте игры, чтобы начать!'
|
||||||
|
: 'Пока нет одобренных игр. Предложите свою!'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { z } from 'zod'
|
|||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { marathonsApi } from '@/api'
|
import { marathonsApi } from '@/api'
|
||||||
import { NeonButton, Input, GlassCard } from '@/components/ui'
|
import { NeonButton, Input, GlassCard } from '@/components/ui'
|
||||||
import { Gamepad2, LogIn, AlertCircle, Trophy, Users, Zap, Target } from 'lucide-react'
|
import { Gamepad2, LogIn, AlertCircle, Trophy, Users, Zap, Target, Shield, ArrowLeft } from 'lucide-react'
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
login: z.string().min(3, 'Логин должен быть не менее 3 символов'),
|
login: z.string().min(3, 'Логин должен быть не менее 3 символов'),
|
||||||
@@ -17,8 +17,9 @@ type LoginForm = z.infer<typeof loginSchema>
|
|||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { login, isLoading, error, clearError, consumePendingInviteCode } = useAuthStore()
|
const { login, verify2FA, cancel2FA, pending2FA, isLoading, error, clearError, consumePendingInviteCode } = useAuthStore()
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
|
const [twoFACode, setTwoFACode] = useState('')
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -32,7 +33,12 @@ export function LoginPage() {
|
|||||||
setSubmitError(null)
|
setSubmitError(null)
|
||||||
clearError()
|
clearError()
|
||||||
try {
|
try {
|
||||||
await login(data)
|
const result = await login(data)
|
||||||
|
|
||||||
|
// If 2FA required, don't navigate
|
||||||
|
if (result.requires2FA) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Check for pending invite code
|
// Check for pending invite code
|
||||||
const pendingCode = consumePendingInviteCode()
|
const pendingCode = consumePendingInviteCode()
|
||||||
@@ -52,6 +58,24 @@ export function LoginPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handle2FASubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSubmitError(null)
|
||||||
|
clearError()
|
||||||
|
try {
|
||||||
|
await verify2FA(twoFACode)
|
||||||
|
navigate('/marathons')
|
||||||
|
} catch {
|
||||||
|
setSubmitError(error || 'Неверный код')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel2FA = () => {
|
||||||
|
cancel2FA()
|
||||||
|
setTwoFACode('')
|
||||||
|
setSubmitError(null)
|
||||||
|
}
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
{ icon: <Trophy className="w-5 h-5" />, text: 'Соревнуйтесь с друзьями' },
|
{ icon: <Trophy className="w-5 h-5" />, text: 'Соревнуйтесь с друзьями' },
|
||||||
{ icon: <Target className="w-5 h-5" />, text: 'Выполняйте челленджи' },
|
{ icon: <Target className="w-5 h-5" />, text: 'Выполняйте челленджи' },
|
||||||
@@ -113,6 +137,63 @@ export function LoginPage() {
|
|||||||
|
|
||||||
{/* Form Block (right) */}
|
{/* Form Block (right) */}
|
||||||
<GlassCard className="p-8">
|
<GlassCard className="p-8">
|
||||||
|
{pending2FA ? (
|
||||||
|
// 2FA Form
|
||||||
|
<>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-neon-500/10 border border-neon-500/30 flex items-center justify-center">
|
||||||
|
<Shield className="w-8 h-8 text-neon-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-2">Двухфакторная аутентификация</h2>
|
||||||
|
<p className="text-gray-400">Введите код из Telegram</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2FA Form */}
|
||||||
|
<form onSubmit={handle2FASubmit} className="space-y-5">
|
||||||
|
{(submitError || error) && (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
|
||||||
|
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span>{submitError || error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Код подтверждения"
|
||||||
|
placeholder="000000"
|
||||||
|
value={twoFACode}
|
||||||
|
onChange={(e) => setTwoFACode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
maxLength={6}
|
||||||
|
className="text-center text-2xl tracking-widest font-mono"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NeonButton
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
isLoading={isLoading}
|
||||||
|
disabled={twoFACode.length !== 6}
|
||||||
|
icon={<Shield className="w-5 h-5" />}
|
||||||
|
>
|
||||||
|
Подтвердить
|
||||||
|
</NeonButton>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Back button */}
|
||||||
|
<div className="mt-6 pt-6 border-t border-dark-600 text-center">
|
||||||
|
<button
|
||||||
|
onClick={handleCancel2FA}
|
||||||
|
className="text-gray-400 hover:text-white transition-colors text-sm flex items-center justify-center gap-2 mx-auto"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Вернуться к входу
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Regular Login Form
|
||||||
|
<>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h2 className="text-2xl font-bold text-white mb-2">Добро пожаловать!</h2>
|
<h2 className="text-2xl font-bold text-white mb-2">Добро пожаловать!</h2>
|
||||||
@@ -168,6 +249,8 @@ export function LoginPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} 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 }>()
|
||||||
@@ -316,6 +317,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} />
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
190
frontend/src/pages/admin/AdminBroadcastPage.tsx
Normal file
190
frontend/src/pages/admin/AdminBroadcastPage.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { adminApi } from '@/api'
|
||||||
|
import type { AdminMarathon } from '@/types'
|
||||||
|
import { useToast } from '@/store/toast'
|
||||||
|
import { NeonButton } from '@/components/ui'
|
||||||
|
import { Send, Users, Trophy, AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
export function AdminBroadcastPage() {
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [targetType, setTargetType] = useState<'all' | 'marathon'>('all')
|
||||||
|
const [marathonId, setMarathonId] = useState<number | null>(null)
|
||||||
|
const [marathons, setMarathons] = useState<AdminMarathon[]>([])
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
const [loadingMarathons, setLoadingMarathons] = useState(false)
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (targetType === 'marathon') {
|
||||||
|
loadMarathons()
|
||||||
|
}
|
||||||
|
}, [targetType])
|
||||||
|
|
||||||
|
const loadMarathons = async () => {
|
||||||
|
setLoadingMarathons(true)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.listMarathons(0, 100)
|
||||||
|
setMarathons(data.filter(m => m.status === 'active'))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load marathons:', err)
|
||||||
|
} finally {
|
||||||
|
setLoadingMarathons(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!message.trim()) {
|
||||||
|
toast.error('Введите сообщение')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetType === 'marathon' && !marathonId) {
|
||||||
|
toast.error('Выберите марафон')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSending(true)
|
||||||
|
try {
|
||||||
|
let result
|
||||||
|
if (targetType === 'all') {
|
||||||
|
result = await adminApi.broadcastToAll(message)
|
||||||
|
} else {
|
||||||
|
result = await adminApi.broadcastToMarathon(marathonId!, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(`Отправлено ${result.sent_count} из ${result.total_count} сообщений`)
|
||||||
|
setMessage('')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to send broadcast:', err)
|
||||||
|
toast.error('Ошибка отправки')
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-pink-500/20 border border-pink-500/30">
|
||||||
|
<Send className="w-6 h-6 text-pink-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Рассылка уведомлений</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-2xl space-y-6">
|
||||||
|
{/* Target Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="block text-sm font-medium text-gray-300">
|
||||||
|
Кому отправить
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTargetType('all')
|
||||||
|
setMarathonId(null)
|
||||||
|
}}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-xl border transition-all duration-200 ${
|
||||||
|
targetType === 'all'
|
||||||
|
? 'bg-accent-500/20 border-accent-500/50 text-accent-400 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
|
||||||
|
: 'bg-dark-700/50 border-dark-600 text-gray-400 hover:border-dark-500 hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Users className="w-5 h-5" />
|
||||||
|
<span className="font-medium">Всем пользователям</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTargetType('marathon')}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-xl border transition-all duration-200 ${
|
||||||
|
targetType === 'marathon'
|
||||||
|
? 'bg-accent-500/20 border-accent-500/50 text-accent-400 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
|
||||||
|
: 'bg-dark-700/50 border-dark-600 text-gray-400 hover:border-dark-500 hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Trophy className="w-5 h-5" />
|
||||||
|
<span className="font-medium">Участникам марафона</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Marathon Selection */}
|
||||||
|
{targetType === 'marathon' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-300">
|
||||||
|
Выберите марафон
|
||||||
|
</label>
|
||||||
|
{loadingMarathons ? (
|
||||||
|
<div className="animate-pulse bg-dark-700 h-12 rounded-xl" />
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={marathonId || ''}
|
||||||
|
onChange={(e) => setMarathonId(Number(e.target.value) || null)}
|
||||||
|
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="">Выберите марафон...</option>
|
||||||
|
{marathons.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.title} ({m.participants_count} участников)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{marathons.length === 0 && !loadingMarathons && (
|
||||||
|
<p className="text-sm text-gray-500">Нет активных марафонов</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-300">
|
||||||
|
Сообщение
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
rows={6}
|
||||||
|
placeholder="Введите текст сообщения... (поддерживается HTML: <b>, <i>, <code>)"
|
||||||
|
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors resize-none"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<p className="text-gray-500">
|
||||||
|
Поддерживается HTML: <b>, <i>, <code>, <a href>
|
||||||
|
</p>
|
||||||
|
<p className={`${message.length > 2000 ? 'text-red-400' : 'text-gray-500'}`}>
|
||||||
|
{message.length} / 2000
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Send Button */}
|
||||||
|
<NeonButton
|
||||||
|
size="lg"
|
||||||
|
color="purple"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={sending || !message.trim() || message.length > 2000 || (targetType === 'marathon' && !marathonId)}
|
||||||
|
isLoading={sending}
|
||||||
|
icon={<Send className="w-5 h-5" />}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{sending ? 'Отправка...' : 'Отправить рассылку'}
|
||||||
|
</NeonButton>
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
<div className="glass rounded-xl p-4 border border-amber-500/20">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-amber-400 font-medium mb-1">Обратите внимание</p>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Сообщение будет отправлено только пользователям с привязанным Telegram.
|
||||||
|
Рассылка ограничена: 1 сообщение всем в минуту, 3 сообщения марафону в минуту.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
300
frontend/src/pages/admin/AdminContentPage.tsx
Normal file
300
frontend/src/pages/admin/AdminContentPage.tsx
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { adminApi } from '@/api'
|
||||||
|
import type { StaticContent } from '@/types'
|
||||||
|
import { useToast } from '@/store/toast'
|
||||||
|
import { NeonButton } from '@/components/ui'
|
||||||
|
import { FileText, Plus, Pencil, X, Save, Code, Trash2 } from 'lucide-react'
|
||||||
|
import { useConfirm } from '@/store/confirm'
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminContentPage() {
|
||||||
|
const [contents, setContents] = useState<StaticContent[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [editing, setEditing] = useState<StaticContent | null>(null)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [formKey, setFormKey] = useState('')
|
||||||
|
const [formTitle, setFormTitle] = useState('')
|
||||||
|
const [formContent, setFormContent] = useState('')
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadContents()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadContents = async () => {
|
||||||
|
try {
|
||||||
|
const data = await adminApi.listContent()
|
||||||
|
setContents(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load contents:', err)
|
||||||
|
toast.error('Ошибка загрузки контента')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (content: StaticContent) => {
|
||||||
|
setEditing(content)
|
||||||
|
setFormKey(content.key)
|
||||||
|
setFormTitle(content.title)
|
||||||
|
setFormContent(content.content)
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setCreating(true)
|
||||||
|
setEditing(null)
|
||||||
|
setFormKey('')
|
||||||
|
setFormTitle('')
|
||||||
|
setFormContent('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setEditing(null)
|
||||||
|
setCreating(false)
|
||||||
|
setFormKey('')
|
||||||
|
setFormTitle('')
|
||||||
|
setFormContent('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formTitle.trim() || !formContent.trim()) {
|
||||||
|
toast.error('Заполните все поля')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (creating && !formKey.trim()) {
|
||||||
|
toast.error('Введите ключ')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
if (creating) {
|
||||||
|
const newContent = await adminApi.createContent(formKey, formTitle, formContent)
|
||||||
|
setContents([...contents, newContent])
|
||||||
|
toast.success('Контент создан')
|
||||||
|
} else if (editing) {
|
||||||
|
const updated = await adminApi.updateContent(editing.key, formTitle, formContent)
|
||||||
|
setContents(contents.map(c => c.id === updated.id ? updated : c))
|
||||||
|
toast.success('Контент обновлён')
|
||||||
|
}
|
||||||
|
handleCancel()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save content:', err)
|
||||||
|
toast.error('Ошибка сохранения')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (content: StaticContent) => {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Удалить контент?',
|
||||||
|
message: `Вы уверены, что хотите удалить "${content.title}"? Это действие нельзя отменить.`,
|
||||||
|
confirmText: 'Удалить',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'danger',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await adminApi.deleteContent(content.key)
|
||||||
|
setContents(contents.filter(c => c.id !== content.id))
|
||||||
|
if (editing?.id === content.id) {
|
||||||
|
handleCancel()
|
||||||
|
}
|
||||||
|
toast.success('Контент удалён')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete content:', err)
|
||||||
|
toast.error('Ошибка удаления')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-2 border-accent-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-neon-500/20 border border-neon-500/30">
|
||||||
|
<FileText className="w-6 h-6 text-neon-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Статический контент</h1>
|
||||||
|
</div>
|
||||||
|
<NeonButton onClick={handleCreate} icon={<Plus className="w-4 h-4" />}>
|
||||||
|
Добавить
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Content List */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{contents.length === 0 ? (
|
||||||
|
<div className="glass rounded-xl border border-dark-600 p-8 text-center">
|
||||||
|
<FileText className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-400">Нет статического контента</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Создайте первую страницу</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
contents.map((content) => (
|
||||||
|
<div
|
||||||
|
key={content.id}
|
||||||
|
className={`glass rounded-xl border p-5 cursor-pointer transition-all duration-200 ${
|
||||||
|
editing?.id === content.id
|
||||||
|
? 'border-accent-500/50 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
|
||||||
|
: 'border-dark-600 hover:border-dark-500'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleEdit(content)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Code className="w-4 h-4 text-neon-400" />
|
||||||
|
<p className="text-sm text-neon-400 font-mono">{content.key}</p>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-white truncate">{content.title}</h3>
|
||||||
|
<p className="text-sm text-gray-400 mt-2 line-clamp-2">
|
||||||
|
{content.content.replace(/<[^>]*>/g, '').slice(0, 100)}...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 ml-3">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleEdit(content)
|
||||||
|
}}
|
||||||
|
className="p-2 text-gray-400 hover:text-accent-400 hover:bg-accent-500/10 rounded-lg transition-colors"
|
||||||
|
title="Редактировать"
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDelete(content)
|
||||||
|
}}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-4 pt-3 border-t border-dark-600">
|
||||||
|
Обновлено: {formatDate(content.updated_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor */}
|
||||||
|
{(editing || creating) && (
|
||||||
|
<div className="glass rounded-xl border border-dark-600 p-6 sticky top-6 h-fit">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
{creating ? (
|
||||||
|
<>
|
||||||
|
<Plus className="w-5 h-5 text-neon-400" />
|
||||||
|
Новый контент
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Pencil className="w-5 h-5 text-accent-400" />
|
||||||
|
Редактирование
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="p-2 text-gray-400 hover:text-white hover:bg-dark-600/50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{creating && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Ключ
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formKey}
|
||||||
|
onChange={(e) => setFormKey(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ''))}
|
||||||
|
placeholder="about-page"
|
||||||
|
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white font-mono placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1.5">
|
||||||
|
Только буквы, цифры, дефисы и подчеркивания
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Заголовок
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formTitle}
|
||||||
|
onChange={(e) => setFormTitle(e.target.value)}
|
||||||
|
placeholder="Заголовок страницы"
|
||||||
|
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Содержимое (HTML)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={formContent}
|
||||||
|
onChange={(e) => setFormContent(e.target.value)}
|
||||||
|
rows={14}
|
||||||
|
placeholder="<p>HTML контент...</p>"
|
||||||
|
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 font-mono text-sm resize-none transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NeonButton
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
isLoading={saving}
|
||||||
|
icon={<Save className="w-4 h-4" />}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
207
frontend/src/pages/admin/AdminDashboardPage.tsx
Normal file
207
frontend/src/pages/admin/AdminDashboardPage.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { adminApi } from '@/api'
|
||||||
|
import type { DashboardStats } from '@/types'
|
||||||
|
import { Users, Trophy, Gamepad2, UserCheck, Ban, Activity, TrendingUp } from 'lucide-react'
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
user_ban: 'Бан пользователя',
|
||||||
|
user_unban: 'Разбан пользователя',
|
||||||
|
user_role_change: 'Изменение роли',
|
||||||
|
marathon_force_finish: 'Принудительное завершение',
|
||||||
|
marathon_delete: 'Удаление марафона',
|
||||||
|
content_update: 'Обновление контента',
|
||||||
|
broadcast_all: 'Рассылка всем',
|
||||||
|
broadcast_marathon: 'Рассылка марафону',
|
||||||
|
admin_login: 'Вход админа',
|
||||||
|
admin_2fa_success: '2FA успех',
|
||||||
|
admin_2fa_fail: '2FA неудача',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_COLORS: Record<string, string> = {
|
||||||
|
user_ban: 'text-red-400',
|
||||||
|
user_unban: 'text-green-400',
|
||||||
|
user_role_change: 'text-accent-400',
|
||||||
|
marathon_force_finish: 'text-orange-400',
|
||||||
|
marathon_delete: 'text-red-400',
|
||||||
|
content_update: 'text-neon-400',
|
||||||
|
broadcast_all: 'text-pink-400',
|
||||||
|
broadcast_marathon: 'text-pink-400',
|
||||||
|
admin_login: 'text-blue-400',
|
||||||
|
admin_2fa_success: 'text-green-400',
|
||||||
|
admin_2fa_fail: 'text-red-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
gradient,
|
||||||
|
glowColor
|
||||||
|
}: {
|
||||||
|
icon: typeof Users
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
gradient: string
|
||||||
|
glowColor: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`glass rounded-xl p-5 border border-dark-600 hover:border-dark-500 transition-all duration-300 hover:shadow-[0_0_20px_${glowColor}]`}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={`p-3 rounded-xl ${gradient} shadow-lg`}>
|
||||||
|
<Icon className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-400">{label}</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{value.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminDashboardPage() {
|
||||||
|
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDashboard()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadDashboard = async () => {
|
||||||
|
try {
|
||||||
|
const data = await adminApi.getDashboard()
|
||||||
|
setStats(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load dashboard:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-2 border-accent-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats) {
|
||||||
|
return (
|
||||||
|
<div className="text-center text-gray-400 py-12">
|
||||||
|
Не удалось загрузить данные
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-accent-500/20 border border-accent-500/30">
|
||||||
|
<TrendingUp className="w-6 h-6 text-accent-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Дашборд</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<StatCard
|
||||||
|
icon={Users}
|
||||||
|
label="Всего пользователей"
|
||||||
|
value={stats.users_count}
|
||||||
|
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
|
||||||
|
glowColor="rgba(59,130,246,0.15)"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Ban}
|
||||||
|
label="Заблокировано"
|
||||||
|
value={stats.banned_users_count}
|
||||||
|
gradient="bg-gradient-to-br from-red-500 to-red-600"
|
||||||
|
glowColor="rgba(239,68,68,0.15)"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Trophy}
|
||||||
|
label="Всего марафонов"
|
||||||
|
value={stats.marathons_count}
|
||||||
|
gradient="bg-gradient-to-br from-accent-500 to-pink-500"
|
||||||
|
glowColor="rgba(139,92,246,0.15)"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Activity}
|
||||||
|
label="Активных марафонов"
|
||||||
|
value={stats.active_marathons_count}
|
||||||
|
gradient="bg-gradient-to-br from-green-500 to-emerald-600"
|
||||||
|
glowColor="rgba(34,197,94,0.15)"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Gamepad2}
|
||||||
|
label="Всего игр"
|
||||||
|
value={stats.games_count}
|
||||||
|
gradient="bg-gradient-to-br from-orange-500 to-amber-500"
|
||||||
|
glowColor="rgba(249,115,22,0.15)"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={UserCheck}
|
||||||
|
label="Участий в марафонах"
|
||||||
|
value={stats.total_participations}
|
||||||
|
gradient="bg-gradient-to-br from-neon-500 to-cyan-500"
|
||||||
|
glowColor="rgba(34,211,238,0.15)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Logs */}
|
||||||
|
<div className="glass rounded-xl border border-dark-600 overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-dark-600">
|
||||||
|
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<Activity className="w-5 h-5 text-accent-400" />
|
||||||
|
Последние действия
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
{stats.recent_logs.length === 0 ? (
|
||||||
|
<p className="text-gray-400 text-center py-4">Нет записей</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{stats.recent_logs.map((log) => (
|
||||||
|
<div
|
||||||
|
key={log.id}
|
||||||
|
className="flex items-start justify-between p-4 bg-dark-700/50 hover:bg-dark-700 rounded-xl border border-dark-600 transition-colors"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className={`font-medium ${ACTION_COLORS[log.action] || 'text-white'}`}>
|
||||||
|
{ACTION_LABELS[log.action] || log.action}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
<span className="text-gray-500">Админ:</span> {log.admin_nickname}
|
||||||
|
<span className="text-gray-600 mx-2">•</span>
|
||||||
|
<span className="text-gray-500">{log.target_type}</span> #{log.target_id}
|
||||||
|
</p>
|
||||||
|
{log.details && (
|
||||||
|
<p className="text-xs text-gray-500 mt-2 font-mono bg-dark-800 rounded px-2 py-1 inline-block">
|
||||||
|
{JSON.stringify(log.details)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500 whitespace-nowrap ml-4">
|
||||||
|
{formatDate(log.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
169
frontend/src/pages/admin/AdminLayout.tsx
Normal file
169
frontend/src/pages/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { Outlet, NavLink, Navigate, Link } from 'react-router-dom'
|
||||||
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { NeonButton } from '@/components/ui'
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
Trophy,
|
||||||
|
ScrollText,
|
||||||
|
Send,
|
||||||
|
FileText,
|
||||||
|
ArrowLeft,
|
||||||
|
Shield,
|
||||||
|
MessageCircle,
|
||||||
|
Sparkles,
|
||||||
|
Lock
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: '/admin', icon: LayoutDashboard, label: 'Дашборд', end: true },
|
||||||
|
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
|
||||||
|
{ to: '/admin/marathons', icon: Trophy, label: 'Марафоны' },
|
||||||
|
{ to: '/admin/logs', icon: ScrollText, label: 'Логи' },
|
||||||
|
{ to: '/admin/broadcast', icon: Send, label: 'Рассылка' },
|
||||||
|
{ to: '/admin/content', icon: FileText, label: 'Контент' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AdminLayout() {
|
||||||
|
const user = useAuthStore((state) => state.user)
|
||||||
|
|
||||||
|
// Only admins can access
|
||||||
|
if (!user || user.role !== 'admin') {
|
||||||
|
return <Navigate to="/" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin without Telegram - show warning
|
||||||
|
if (!user.telegram_id) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[70vh] flex flex-col items-center justify-center text-center px-4">
|
||||||
|
{/* Background effects */}
|
||||||
|
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<div className="absolute top-1/3 -left-32 w-96 h-96 bg-amber-500/5 rounded-full blur-[100px]" />
|
||||||
|
<div className="absolute bottom-1/3 -right-32 w-96 h-96 bg-accent-500/5 rounded-full blur-[100px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="relative mb-8 animate-float">
|
||||||
|
<div className="w-32 h-32 rounded-3xl bg-dark-700/50 border border-amber-500/30 flex items-center justify-center shadow-[0_0_30px_rgba(245,158,11,0.15)]">
|
||||||
|
<Lock className="w-16 h-16 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute -bottom-2 -right-2 w-12 h-12 rounded-xl bg-accent-500/20 border border-accent-500/30 flex items-center justify-center">
|
||||||
|
<Shield className="w-6 h-6 text-accent-400" />
|
||||||
|
</div>
|
||||||
|
{/* Decorative dots */}
|
||||||
|
<div className="absolute -top-2 -left-2 w-3 h-3 rounded-full bg-amber-500/50 animate-pulse" />
|
||||||
|
<div className="absolute top-4 -right-4 w-2 h-2 rounded-full bg-accent-500/50 animate-pulse" style={{ animationDelay: '0.5s' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title with glow */}
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<h1 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 via-orange-400 to-accent-400">
|
||||||
|
Требуется Telegram
|
||||||
|
</h1>
|
||||||
|
<div className="absolute inset-0 text-3xl font-bold text-amber-500/20 blur-xl">
|
||||||
|
Требуется Telegram
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-400 mb-2 max-w-md">
|
||||||
|
Для доступа к админ-панели необходимо привязать Telegram-аккаунт.
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 text-sm mb-8 max-w-md">
|
||||||
|
Это требуется для двухфакторной аутентификации при входе.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Info card */}
|
||||||
|
<div className="glass rounded-xl p-4 mb-8 max-w-md border border-amber-500/20">
|
||||||
|
<div className="flex items-center gap-2 text-amber-400 mb-2">
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-semibold">Двухфакторная аутентификация</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
|
После привязки Telegram при входе в админ-панель вам будет отправляться код подтверждения.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Link to="/profile">
|
||||||
|
<NeonButton size="lg" color="purple" icon={<MessageCircle className="w-5 h-5" />}>
|
||||||
|
Привязать Telegram
|
||||||
|
</NeonButton>
|
||||||
|
</Link>
|
||||||
|
<Link to="/marathons">
|
||||||
|
<NeonButton size="lg" variant="secondary" icon={<ArrowLeft className="w-5 h-5" />}>
|
||||||
|
На сайт
|
||||||
|
</NeonButton>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decorative sparkles */}
|
||||||
|
<div className="absolute top-1/4 left-1/4 opacity-20">
|
||||||
|
<Sparkles className="w-6 h-6 text-amber-400 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-1/3 right-1/4 opacity-20">
|
||||||
|
<Sparkles className="w-4 h-4 text-accent-400 animate-pulse" style={{ animationDelay: '1s' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-[calc(100vh-64px)]">
|
||||||
|
{/* Background effects */}
|
||||||
|
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<div className="absolute top-0 -left-32 w-96 h-96 bg-accent-500/5 rounded-full blur-[100px]" />
|
||||||
|
<div className="absolute bottom-0 -right-32 w-96 h-96 bg-neon-500/5 rounded-full blur-[100px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-64 glass border-r border-dark-600 flex flex-col relative z-10">
|
||||||
|
<div className="p-4 border-b border-dark-600">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-accent-500 to-pink-500 flex items-center justify-center">
|
||||||
|
<Shield className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-accent-400 to-pink-400">
|
||||||
|
Админ-панель
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 p-4 space-y-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.end}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30 shadow-[0_0_10px_rgba(139,92,246,0.15)]'
|
||||||
|
: 'text-gray-400 hover:bg-dark-600/50 hover:text-white border border-transparent'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="w-5 h-5" />
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-dark-600">
|
||||||
|
<NavLink
|
||||||
|
to="/marathons"
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 text-gray-400 hover:text-neon-400 transition-colors rounded-lg hover:bg-dark-600/50"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
<span className="font-medium">Вернуться на сайт</span>
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1 p-6 overflow-auto relative z-10">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
208
frontend/src/pages/admin/AdminLogsPage.tsx
Normal file
208
frontend/src/pages/admin/AdminLogsPage.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { adminApi } from '@/api'
|
||||||
|
import type { AdminLog } from '@/types'
|
||||||
|
import { useToast } from '@/store/toast'
|
||||||
|
import { ChevronLeft, ChevronRight, Filter, ScrollText } from 'lucide-react'
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
user_ban: 'Бан пользователя',
|
||||||
|
user_unban: 'Разбан пользователя',
|
||||||
|
user_auto_unban: 'Авто-разбан (система)',
|
||||||
|
user_role_change: 'Изменение роли',
|
||||||
|
marathon_force_finish: 'Принудительное завершение',
|
||||||
|
marathon_delete: 'Удаление марафона',
|
||||||
|
content_update: 'Обновление контента',
|
||||||
|
broadcast_all: 'Рассылка всем',
|
||||||
|
broadcast_marathon: 'Рассылка марафону',
|
||||||
|
admin_login: 'Вход админа',
|
||||||
|
admin_2fa_success: '2FA успех',
|
||||||
|
admin_2fa_fail: '2FA неудача',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_COLORS: Record<string, string> = {
|
||||||
|
user_ban: 'bg-red-500/20 text-red-400 border border-red-500/30',
|
||||||
|
user_unban: 'bg-green-500/20 text-green-400 border border-green-500/30',
|
||||||
|
user_auto_unban: 'bg-cyan-500/20 text-cyan-400 border border-cyan-500/30',
|
||||||
|
user_role_change: 'bg-accent-500/20 text-accent-400 border border-accent-500/30',
|
||||||
|
marathon_force_finish: 'bg-orange-500/20 text-orange-400 border border-orange-500/30',
|
||||||
|
marathon_delete: 'bg-red-500/20 text-red-400 border border-red-500/30',
|
||||||
|
content_update: 'bg-neon-500/20 text-neon-400 border border-neon-500/30',
|
||||||
|
broadcast_all: 'bg-pink-500/20 text-pink-400 border border-pink-500/30',
|
||||||
|
broadcast_marathon: 'bg-pink-500/20 text-pink-400 border border-pink-500/30',
|
||||||
|
admin_login: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
|
||||||
|
admin_2fa_success: 'bg-green-500/20 text-green-400 border border-green-500/30',
|
||||||
|
admin_2fa_fail: 'bg-red-500/20 text-red-400 border border-red-500/30',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminLogsPage() {
|
||||||
|
const [logs, setLogs] = useState<AdminLog[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [actionFilter, setActionFilter] = useState<string>('')
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const LIMIT = 30
|
||||||
|
|
||||||
|
const loadLogs = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.getLogs(page * LIMIT, LIMIT, actionFilter || undefined)
|
||||||
|
setLogs(data.logs)
|
||||||
|
setTotal(data.total)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load logs:', err)
|
||||||
|
toast.error('Ошибка загрузки логов')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [page, actionFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadLogs()
|
||||||
|
}, [loadLogs])
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / LIMIT)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-orange-500/20 border border-orange-500/30">
|
||||||
|
<ScrollText className="w-6 h-6 text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Логи действий</h1>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-400 bg-dark-700/50 px-3 py-1.5 rounded-lg border border-dark-600">
|
||||||
|
Всего: <span className="text-white font-medium">{total}</span> записей
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Filter className="w-5 h-5 text-gray-500" />
|
||||||
|
<select
|
||||||
|
value={actionFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setActionFilter(e.target.value)
|
||||||
|
setPage(0)
|
||||||
|
}}
|
||||||
|
className="bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white focus:outline-none focus:border-accent-500/50 transition-colors min-w-[200px]"
|
||||||
|
>
|
||||||
|
<option value="">Все действия</option>
|
||||||
|
{Object.entries(ACTION_LABELS).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logs Table */}
|
||||||
|
<div className="glass rounded-xl border border-dark-600 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-dark-700/50 border-b border-dark-600">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Дата</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Админ</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Действие</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Цель</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Детали</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-dark-600">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 py-8 text-center">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
|
||||||
|
Логи не найдены
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
logs.map((log) => (
|
||||||
|
<tr key={log.id} className="hover:bg-dark-700/30 transition-colors">
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-400 whitespace-nowrap font-mono">
|
||||||
|
{formatDate(log.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm font-medium">
|
||||||
|
{log.admin_nickname ? (
|
||||||
|
<span className="text-white">{log.admin_nickname}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-cyan-400 italic">Система</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-flex px-2.5 py-1 text-xs font-medium rounded-lg ${ACTION_COLORS[log.action] || 'bg-dark-600/50 text-gray-400 border border-dark-500'}`}>
|
||||||
|
{ACTION_LABELS[log.action] || log.action}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-400">
|
||||||
|
<span className="text-gray-500">{log.target_type}</span>
|
||||||
|
<span className="text-neon-400 font-mono ml-1">#{log.target_id}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-500 max-w-xs">
|
||||||
|
{log.details ? (
|
||||||
|
<span className="font-mono text-xs bg-dark-700/50 px-2 py-1 rounded truncate block">
|
||||||
|
{JSON.stringify(log.details)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-600">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-500 font-mono">
|
||||||
|
{log.ip_address || '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-dark-600">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(Math.max(0, page - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
Страница <span className="text-white font-medium">{page + 1}</span> из <span className="text-white font-medium">{totalPages || 1}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
|
||||||
|
>
|
||||||
|
Вперед
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
242
frontend/src/pages/admin/AdminMarathonsPage.tsx
Normal file
242
frontend/src/pages/admin/AdminMarathonsPage.tsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { adminApi } from '@/api'
|
||||||
|
import type { AdminMarathon } from '@/types'
|
||||||
|
import { useToast } from '@/store/toast'
|
||||||
|
import { useConfirm } from '@/store/confirm'
|
||||||
|
import { NeonButton } from '@/components/ui'
|
||||||
|
import { Search, Trash2, StopCircle, ChevronLeft, ChevronRight, Trophy, Clock, CheckCircle, Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<string, { label: string; icon: typeof Clock; className: string }> = {
|
||||||
|
preparing: {
|
||||||
|
label: 'Подготовка',
|
||||||
|
icon: Loader2,
|
||||||
|
className: 'bg-amber-500/20 text-amber-400 border border-amber-500/30'
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
label: 'Активный',
|
||||||
|
icon: Clock,
|
||||||
|
className: 'bg-green-500/20 text-green-400 border border-green-500/30'
|
||||||
|
},
|
||||||
|
finished: {
|
||||||
|
label: 'Завершён',
|
||||||
|
icon: CheckCircle,
|
||||||
|
className: 'bg-dark-600/50 text-gray-400 border border-dark-500'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null) {
|
||||||
|
if (!dateStr) return '—'
|
||||||
|
return new Date(dateStr).toLocaleDateString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminMarathonsPage() {
|
||||||
|
const [marathons, setMarathons] = useState<AdminMarathon[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
|
||||||
|
const LIMIT = 20
|
||||||
|
|
||||||
|
const loadMarathons = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.listMarathons(page * LIMIT, LIMIT, search || undefined)
|
||||||
|
setMarathons(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load marathons:', err)
|
||||||
|
toast.error('Ошибка загрузки марафонов')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [page, search])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMarathons()
|
||||||
|
}, [loadMarathons])
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setPage(0)
|
||||||
|
loadMarathons()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (marathon: AdminMarathon) => {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Удалить марафон',
|
||||||
|
message: `Вы уверены, что хотите удалить марафон "${marathon.title}"? Это действие необратимо.`,
|
||||||
|
confirmText: 'Удалить',
|
||||||
|
variant: 'danger',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await adminApi.deleteMarathon(marathon.id)
|
||||||
|
setMarathons(marathons.filter(m => m.id !== marathon.id))
|
||||||
|
toast.success('Марафон удалён')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete marathon:', err)
|
||||||
|
toast.error('Ошибка удаления')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleForceFinish = async (marathon: AdminMarathon) => {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Завершить марафон',
|
||||||
|
message: `Принудительно завершить марафон "${marathon.title}"? Участники получат уведомление.`,
|
||||||
|
confirmText: 'Завершить',
|
||||||
|
variant: 'warning',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await adminApi.forceFinishMarathon(marathon.id)
|
||||||
|
setMarathons(marathons.map(m =>
|
||||||
|
m.id === marathon.id ? { ...m, status: 'finished' } : m
|
||||||
|
))
|
||||||
|
toast.success('Марафон завершён')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to finish marathon:', err)
|
||||||
|
toast.error('Ошибка завершения')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-accent-500/20 border border-accent-500/30">
|
||||||
|
<Trophy className="w-6 h-6 text-accent-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Марафоны</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<form onSubmit={handleSearch} className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск по названию..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl pl-10 pr-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/20 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<NeonButton type="submit" color="purple">
|
||||||
|
Найти
|
||||||
|
</NeonButton>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Marathons Table */}
|
||||||
|
<div className="glass rounded-xl border border-dark-600 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-dark-700/50 border-b border-dark-600">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">ID</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Название</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Создатель</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Участники</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Игры</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Даты</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-dark-600">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-8 text-center">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : marathons.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
||||||
|
Марафоны не найдены
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
marathons.map((marathon) => {
|
||||||
|
const statusConfig = STATUS_CONFIG[marathon.status] || STATUS_CONFIG.finished
|
||||||
|
const StatusIcon = statusConfig.icon
|
||||||
|
return (
|
||||||
|
<tr key={marathon.id} className="hover:bg-dark-700/30 transition-colors">
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-400 font-mono">{marathon.id}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-white font-medium">{marathon.title}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-300">{marathon.creator.nickname}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg ${statusConfig.className}`}>
|
||||||
|
<StatusIcon className={`w-3 h-3 ${marathon.status === 'preparing' ? 'animate-spin' : ''}`} />
|
||||||
|
{statusConfig.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-400">{marathon.participants_count}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-400">{marathon.games_count}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-400">
|
||||||
|
<span className="text-gray-500">{formatDate(marathon.start_date)}</span>
|
||||||
|
<span className="text-gray-600 mx-1">→</span>
|
||||||
|
<span className="text-gray-500">{formatDate(marathon.end_date)}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{marathon.status !== 'finished' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleForceFinish(marathon)}
|
||||||
|
className="p-2 text-orange-400 hover:bg-orange-500/20 rounded-lg transition-colors"
|
||||||
|
title="Завершить марафон"
|
||||||
|
>
|
||||||
|
<StopCircle className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(marathon)}
|
||||||
|
className="p-2 text-red-400 hover:bg-red-500/20 rounded-lg transition-colors"
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-dark-600">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(Math.max(0, page - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
Страница <span className="text-white font-medium">{page + 1}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={marathons.length < LIMIT}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
|
||||||
|
>
|
||||||
|
Вперед
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
492
frontend/src/pages/admin/AdminUsersPage.tsx
Normal file
492
frontend/src/pages/admin/AdminUsersPage.tsx
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { adminApi } from '@/api'
|
||||||
|
import type { AdminUser, UserRole } from '@/types'
|
||||||
|
import { useToast } from '@/store/toast'
|
||||||
|
import { useConfirm } from '@/store/confirm'
|
||||||
|
import { NeonButton } from '@/components/ui'
|
||||||
|
import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X, KeyRound } from 'lucide-react'
|
||||||
|
|
||||||
|
export function AdminUsersPage() {
|
||||||
|
const [users, setUsers] = useState<AdminUser[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [bannedOnly, setBannedOnly] = useState(false)
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const [banModalUser, setBanModalUser] = useState<AdminUser | null>(null)
|
||||||
|
const [banReason, setBanReason] = useState('')
|
||||||
|
const [banDuration, setBanDuration] = useState<string>('permanent')
|
||||||
|
const [banCustomDate, setBanCustomDate] = useState('')
|
||||||
|
const [banning, setBanning] = useState(false)
|
||||||
|
const [resetPasswordUser, setResetPasswordUser] = useState<AdminUser | null>(null)
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [resettingPassword, setResettingPassword] = useState(false)
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
|
||||||
|
const LIMIT = 20
|
||||||
|
|
||||||
|
const loadUsers = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.listUsers(page * LIMIT, LIMIT, search || undefined, bannedOnly)
|
||||||
|
setUsers(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load users:', err)
|
||||||
|
toast.error('Ошибка загрузки пользователей')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [page, search, bannedOnly])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsers()
|
||||||
|
}, [loadUsers])
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setPage(0)
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBan = async () => {
|
||||||
|
if (!banModalUser || !banReason.trim()) return
|
||||||
|
|
||||||
|
let bannedUntil: string | undefined
|
||||||
|
if (banDuration !== 'permanent') {
|
||||||
|
const now = new Date()
|
||||||
|
if (banDuration === '1d') {
|
||||||
|
now.setDate(now.getDate() + 1)
|
||||||
|
bannedUntil = now.toISOString()
|
||||||
|
} else if (banDuration === '7d') {
|
||||||
|
now.setDate(now.getDate() + 7)
|
||||||
|
bannedUntil = now.toISOString()
|
||||||
|
} else if (banDuration === '30d') {
|
||||||
|
now.setDate(now.getDate() + 30)
|
||||||
|
bannedUntil = now.toISOString()
|
||||||
|
} else if (banDuration === 'custom' && banCustomDate) {
|
||||||
|
bannedUntil = new Date(banCustomDate).toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setBanning(true)
|
||||||
|
try {
|
||||||
|
const updated = await adminApi.banUser(banModalUser.id, banReason, bannedUntil)
|
||||||
|
setUsers(users.map(u => u.id === updated.id ? updated : u))
|
||||||
|
toast.success(`Пользователь ${updated.nickname} заблокирован`)
|
||||||
|
setBanModalUser(null)
|
||||||
|
setBanReason('')
|
||||||
|
setBanDuration('permanent')
|
||||||
|
setBanCustomDate('')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to ban user:', err)
|
||||||
|
toast.error('Ошибка блокировки')
|
||||||
|
} finally {
|
||||||
|
setBanning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUnban = async (user: AdminUser) => {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Разблокировать пользователя',
|
||||||
|
message: `Вы уверены, что хотите разблокировать ${user.nickname}?`,
|
||||||
|
confirmText: 'Разблокировать',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await adminApi.unbanUser(user.id)
|
||||||
|
setUsers(users.map(u => u.id === updated.id ? updated : u))
|
||||||
|
toast.success(`Пользователь ${updated.nickname} разблокирован`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to unban user:', err)
|
||||||
|
toast.error('Ошибка разблокировки')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRoleChange = async (user: AdminUser, newRole: UserRole) => {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Изменить роль',
|
||||||
|
message: `Изменить роль ${user.nickname} на ${newRole === 'admin' ? 'Администратор' : 'Пользователь'}?`,
|
||||||
|
confirmText: 'Изменить',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await adminApi.setUserRole(user.id, newRole)
|
||||||
|
setUsers(users.map(u => u.id === updated.id ? updated : u))
|
||||||
|
toast.success(`Роль ${updated.nickname} изменена`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to change role:', err)
|
||||||
|
toast.error('Ошибка изменения роли')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResetPassword = async () => {
|
||||||
|
if (!resetPasswordUser || !newPassword.trim() || newPassword.length < 6) return
|
||||||
|
|
||||||
|
setResettingPassword(true)
|
||||||
|
try {
|
||||||
|
const updated = await adminApi.resetUserPassword(resetPasswordUser.id, newPassword)
|
||||||
|
setUsers(users.map(u => u.id === updated.id ? updated : u))
|
||||||
|
toast.success(`Пароль ${updated.nickname} сброшен`)
|
||||||
|
setResetPasswordUser(null)
|
||||||
|
setNewPassword('')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to reset password:', err)
|
||||||
|
toast.error('Ошибка сброса пароля')
|
||||||
|
} finally {
|
||||||
|
setResettingPassword(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-blue-500/20 border border-blue-500/30">
|
||||||
|
<Users className="w-6 h-6 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Пользователи</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<form onSubmit={handleSearch} className="flex-1 flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск по логину или никнейму..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl pl-10 pr-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/20 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<NeonButton type="submit" color="purple">
|
||||||
|
Найти
|
||||||
|
</NeonButton>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-gray-300 cursor-pointer group">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={bannedOnly}
|
||||||
|
onChange={(e) => {
|
||||||
|
setBannedOnly(e.target.checked)
|
||||||
|
setPage(0)
|
||||||
|
}}
|
||||||
|
className="w-4 h-4 rounded border-dark-600 bg-dark-700 text-accent-500 focus:ring-accent-500/50 focus:ring-offset-0"
|
||||||
|
/>
|
||||||
|
<span className="group-hover:text-white transition-colors">Только заблокированные</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Users Table */}
|
||||||
|
<div className="glass rounded-xl border border-dark-600 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-dark-700/50 border-b border-dark-600">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">ID</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Логин</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Никнейм</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Роль</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Telegram</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Марафоны</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-dark-600">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-8 text-center">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
||||||
|
Пользователи не найдены
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
users.map((user) => (
|
||||||
|
<tr key={user.id} className="hover:bg-dark-700/30 transition-colors">
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-400 font-mono">{user.id}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-white">{user.login}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-white font-medium">{user.nickname}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg ${
|
||||||
|
user.role === 'admin'
|
||||||
|
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30'
|
||||||
|
: 'bg-dark-600/50 text-gray-400 border border-dark-500'
|
||||||
|
}`}>
|
||||||
|
{user.role === 'admin' && <Shield className="w-3 h-3" />}
|
||||||
|
{user.role === 'admin' ? 'Админ' : 'Пользователь'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-400">
|
||||||
|
{user.telegram_username ? (
|
||||||
|
<span className="text-neon-400">@{user.telegram_username}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-600">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-400">{user.marathons_count}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{user.is_banned ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-red-500/20 text-red-400 border border-red-500/30">
|
||||||
|
<Ban className="w-3 h-3" />
|
||||||
|
Заблокирован
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-green-500/20 text-green-400 border border-green-500/30">
|
||||||
|
<UserCheck className="w-3 h-3" />
|
||||||
|
Активен
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{user.is_banned ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleUnban(user)}
|
||||||
|
className="p-2 text-green-400 hover:bg-green-500/20 rounded-lg transition-colors"
|
||||||
|
title="Разблокировать"
|
||||||
|
>
|
||||||
|
<UserCheck className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
) : user.role !== 'admin' ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setBanModalUser(user)}
|
||||||
|
className="p-2 text-red-400 hover:bg-red-500/20 rounded-lg transition-colors"
|
||||||
|
title="Заблокировать"
|
||||||
|
>
|
||||||
|
<Ban className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{user.role === 'admin' ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRoleChange(user, 'user')}
|
||||||
|
className="p-2 text-orange-400 hover:bg-orange-500/20 rounded-lg transition-colors"
|
||||||
|
title="Снять права админа"
|
||||||
|
>
|
||||||
|
<ShieldOff className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRoleChange(user, 'admin')}
|
||||||
|
className="p-2 text-accent-400 hover:bg-accent-500/20 rounded-lg transition-colors"
|
||||||
|
title="Сделать админом"
|
||||||
|
>
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setResetPasswordUser(user)}
|
||||||
|
className="p-2 text-yellow-400 hover:bg-yellow-500/20 rounded-lg transition-colors"
|
||||||
|
title="Сбросить пароль"
|
||||||
|
>
|
||||||
|
<KeyRound className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-dark-600">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(Math.max(0, page - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
Страница <span className="text-white font-medium">{page + 1}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={users.length < LIMIT}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
|
||||||
|
>
|
||||||
|
Вперед
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ban Modal */}
|
||||||
|
{banModalUser && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
|
<div className="glass rounded-2xl p-6 max-w-md w-full mx-4 border border-dark-600 shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<Ban className="w-5 h-5 text-red-400" />
|
||||||
|
Заблокировать {banModalUser.nickname}?
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setBanModalUser(null)
|
||||||
|
setBanReason('')
|
||||||
|
setBanDuration('permanent')
|
||||||
|
setBanCustomDate('')
|
||||||
|
}}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-white rounded-lg hover:bg-dark-600/50 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ban Duration */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Срок блокировки
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={banDuration}
|
||||||
|
onChange={(e) => setBanDuration(e.target.value)}
|
||||||
|
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="permanent">Навсегда</option>
|
||||||
|
<option value="1d">1 день</option>
|
||||||
|
<option value="7d">7 дней</option>
|
||||||
|
<option value="30d">30 дней</option>
|
||||||
|
<option value="custom">Указать дату</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Date */}
|
||||||
|
{banDuration === 'custom' && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Разблокировать
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={banCustomDate}
|
||||||
|
onChange={(e) => setBanCustomDate(e.target.value)}
|
||||||
|
min={new Date().toISOString().slice(0, 16)}
|
||||||
|
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reason */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Причина
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={banReason}
|
||||||
|
onChange={(e) => setBanReason(e.target.value)}
|
||||||
|
placeholder="Причина блокировки..."
|
||||||
|
rows={3}
|
||||||
|
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<NeonButton
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setBanModalUser(null)
|
||||||
|
setBanReason('')
|
||||||
|
setBanDuration('permanent')
|
||||||
|
setBanCustomDate('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</NeonButton>
|
||||||
|
<NeonButton
|
||||||
|
variant="danger"
|
||||||
|
onClick={handleBan}
|
||||||
|
disabled={!banReason.trim() || banning || (banDuration === 'custom' && !banCustomDate)}
|
||||||
|
isLoading={banning}
|
||||||
|
icon={<Ban className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Заблокировать
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reset Password Modal */}
|
||||||
|
{resetPasswordUser && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
|
<div className="glass rounded-2xl p-6 max-w-md w-full mx-4 border border-dark-600 shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<KeyRound className="w-5 h-5 text-yellow-400" />
|
||||||
|
Сбросить пароль {resetPasswordUser.nickname}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setResetPasswordUser(null)
|
||||||
|
setNewPassword('')
|
||||||
|
}}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-white rounded-lg hover:bg-dark-600/50 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Новый пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="Минимум 6 символов"
|
||||||
|
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||||
|
/>
|
||||||
|
{newPassword && newPassword.length < 6 && (
|
||||||
|
<p className="mt-2 text-sm text-red-400">Пароль должен быть минимум 6 символов</p>
|
||||||
|
)}
|
||||||
|
{resetPasswordUser.telegram_id && (
|
||||||
|
<p className="mt-2 text-sm text-gray-400">
|
||||||
|
Пользователь получит уведомление в Telegram о смене пароля
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<NeonButton
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setResetPasswordUser(null)
|
||||||
|
setNewPassword('')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</NeonButton>
|
||||||
|
<NeonButton
|
||||||
|
color="neon"
|
||||||
|
onClick={handleResetPassword}
|
||||||
|
disabled={!newPassword.trim() || newPassword.length < 6 || resettingPassword}
|
||||||
|
isLoading={resettingPassword}
|
||||||
|
icon={<KeyRound className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
frontend/src/pages/admin/index.ts
Normal file
7
frontend/src/pages/admin/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { AdminLayout } from './AdminLayout'
|
||||||
|
export { AdminDashboardPage } from './AdminDashboardPage'
|
||||||
|
export { AdminUsersPage } from './AdminUsersPage'
|
||||||
|
export { AdminMarathonsPage } from './AdminMarathonsPage'
|
||||||
|
export { AdminLogsPage } from './AdminLogsPage'
|
||||||
|
export { AdminBroadcastPage } from './AdminBroadcastPage'
|
||||||
|
export { AdminContentPage } from './AdminContentPage'
|
||||||
@@ -3,6 +3,23 @@ import { persist } from 'zustand/middleware'
|
|||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
import { authApi, type RegisterData, type LoginData } from '@/api/auth'
|
import { authApi, type RegisterData, type LoginData } from '@/api/auth'
|
||||||
|
|
||||||
|
let syncPromise: Promise<void> | null = null
|
||||||
|
|
||||||
|
interface Pending2FA {
|
||||||
|
sessionId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginResult {
|
||||||
|
requires2FA: boolean
|
||||||
|
sessionId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BanInfo {
|
||||||
|
banned_at: string | null
|
||||||
|
banned_until: string | null
|
||||||
|
reason: string | null
|
||||||
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: User | null
|
user: User | null
|
||||||
token: string | null
|
token: string | null
|
||||||
@@ -11,8 +28,12 @@ interface AuthState {
|
|||||||
error: string | null
|
error: string | null
|
||||||
pendingInviteCode: string | null
|
pendingInviteCode: string | null
|
||||||
avatarVersion: number
|
avatarVersion: number
|
||||||
|
pending2FA: Pending2FA | null
|
||||||
|
banInfo: BanInfo | null
|
||||||
|
|
||||||
login: (data: LoginData) => Promise<void>
|
login: (data: LoginData) => Promise<LoginResult>
|
||||||
|
verify2FA: (code: string) => Promise<void>
|
||||||
|
cancel2FA: () => void
|
||||||
register: (data: RegisterData) => Promise<void>
|
register: (data: RegisterData) => Promise<void>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
clearError: () => void
|
clearError: () => void
|
||||||
@@ -20,6 +41,9 @@ interface AuthState {
|
|||||||
consumePendingInviteCode: () => string | null
|
consumePendingInviteCode: () => string | null
|
||||||
updateUser: (updates: Partial<User>) => void
|
updateUser: (updates: Partial<User>) => void
|
||||||
bumpAvatarVersion: () => void
|
bumpAvatarVersion: () => void
|
||||||
|
setBanned: (banInfo: BanInfo) => void
|
||||||
|
clearBanned: () => void
|
||||||
|
syncUser: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
@@ -32,11 +56,25 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
error: null,
|
error: null,
|
||||||
pendingInviteCode: null,
|
pendingInviteCode: null,
|
||||||
avatarVersion: 0,
|
avatarVersion: 0,
|
||||||
|
pending2FA: null,
|
||||||
|
banInfo: null,
|
||||||
|
|
||||||
login: async (data) => {
|
login: async (data) => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null, pending2FA: null })
|
||||||
try {
|
try {
|
||||||
const response = await authApi.login(data)
|
const response = await authApi.login(data)
|
||||||
|
|
||||||
|
// Check if 2FA is required
|
||||||
|
if (response.requires_2fa && response.two_factor_session_id) {
|
||||||
|
set({
|
||||||
|
isLoading: false,
|
||||||
|
pending2FA: { sessionId: response.two_factor_session_id },
|
||||||
|
})
|
||||||
|
return { requires2FA: true, sessionId: response.two_factor_session_id }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular login (no 2FA)
|
||||||
|
if (response.access_token && response.user) {
|
||||||
localStorage.setItem('token', response.access_token)
|
localStorage.setItem('token', response.access_token)
|
||||||
set({
|
set({
|
||||||
user: response.user,
|
user: response.user,
|
||||||
@@ -44,6 +82,8 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
return { requires2FA: false }
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
set({
|
set({
|
||||||
@@ -54,6 +94,37 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
verify2FA: async (code) => {
|
||||||
|
const pending = get().pending2FA
|
||||||
|
if (!pending) {
|
||||||
|
throw new Error('No pending 2FA session')
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: true, error: null })
|
||||||
|
try {
|
||||||
|
const response = await authApi.verify2FA(pending.sessionId, code)
|
||||||
|
localStorage.setItem('token', response.access_token)
|
||||||
|
set({
|
||||||
|
user: response.user,
|
||||||
|
token: response.access_token,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
pending2FA: null,
|
||||||
|
})
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
|
set({
|
||||||
|
error: error.response?.data?.detail || '2FA verification failed',
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel2FA: () => {
|
||||||
|
set({ pending2FA: null, error: null })
|
||||||
|
},
|
||||||
|
|
||||||
register: async (data) => {
|
register: async (data) => {
|
||||||
set({ isLoading: true, error: null })
|
set({ isLoading: true, error: null })
|
||||||
try {
|
try {
|
||||||
@@ -77,10 +148,12 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
|
sessionStorage.removeItem('telegram_banner_dismissed')
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
token: null,
|
token: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
banInfo: null,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -104,6 +177,35 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
bumpAvatarVersion: () => {
|
bumpAvatarVersion: () => {
|
||||||
set({ avatarVersion: get().avatarVersion + 1 })
|
set({ avatarVersion: get().avatarVersion + 1 })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setBanned: (banInfo) => {
|
||||||
|
set({ banInfo })
|
||||||
|
},
|
||||||
|
|
||||||
|
clearBanned: () => {
|
||||||
|
set({ banInfo: null })
|
||||||
|
},
|
||||||
|
|
||||||
|
syncUser: async () => {
|
||||||
|
if (!get().isAuthenticated || !get().token) return
|
||||||
|
|
||||||
|
// Prevent duplicate sync calls
|
||||||
|
if (syncPromise) return syncPromise
|
||||||
|
|
||||||
|
syncPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const userData = await authApi.me()
|
||||||
|
set({ user: userData })
|
||||||
|
} catch {
|
||||||
|
// Token invalid - logout
|
||||||
|
get().logout()
|
||||||
|
} finally {
|
||||||
|
syncPromise = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return syncPromise
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'auth-storage',
|
name: 'auth-storage',
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ export interface TokenResponse {
|
|||||||
user: User
|
user: User
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Login response (may require 2FA for admins)
|
||||||
|
export interface LoginResponse {
|
||||||
|
access_token?: string | null
|
||||||
|
token_type: string
|
||||||
|
user?: User | null
|
||||||
|
requires_2fa: boolean
|
||||||
|
two_factor_session_id?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
// Marathon types
|
// Marathon types
|
||||||
export type MarathonStatus = 'preparing' | 'active' | 'finished'
|
export type MarathonStatus = 'preparing' | 'active' | 'finished'
|
||||||
export type ParticipantRole = 'participant' | 'organizer'
|
export type ParticipantRole = 'participant' | 'organizer'
|
||||||
@@ -135,6 +144,13 @@ export type ChallengeType =
|
|||||||
export type Difficulty = 'easy' | 'medium' | 'hard'
|
export type Difficulty = 'easy' | 'medium' | 'hard'
|
||||||
export type ProofType = 'screenshot' | 'video' | 'steam'
|
export type ProofType = 'screenshot' | 'video' | 'steam'
|
||||||
|
|
||||||
|
export type ChallengeStatus = 'pending' | 'approved' | 'rejected'
|
||||||
|
|
||||||
|
export interface ProposedByUser {
|
||||||
|
id: number
|
||||||
|
nickname: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Challenge {
|
export interface Challenge {
|
||||||
id: number
|
id: number
|
||||||
game: GameShort
|
game: GameShort
|
||||||
@@ -148,6 +164,8 @@ export interface Challenge {
|
|||||||
proof_hint: string | null
|
proof_hint: string | null
|
||||||
is_generated: boolean
|
is_generated: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
|
status: ChallengeStatus
|
||||||
|
proposed_by: ProposedByUser | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChallengePreview {
|
export interface ChallengePreview {
|
||||||
@@ -395,6 +413,10 @@ export interface AdminUser {
|
|||||||
telegram_username: string | null
|
telegram_username: string | null
|
||||||
marathons_count: number
|
marathons_count: number
|
||||||
created_at: string
|
created_at: string
|
||||||
|
is_banned: boolean
|
||||||
|
banned_at: string | null
|
||||||
|
banned_until: string | null // null = permanent ban
|
||||||
|
ban_reason: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminMarathon {
|
export interface AdminMarathon {
|
||||||
@@ -416,6 +438,64 @@ export interface PlatformStats {
|
|||||||
total_participations: number
|
total_participations: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin action log types
|
||||||
|
export type AdminActionType =
|
||||||
|
| 'user_ban'
|
||||||
|
| 'user_unban'
|
||||||
|
| 'user_role_change'
|
||||||
|
| 'marathon_force_finish'
|
||||||
|
| 'marathon_delete'
|
||||||
|
| 'content_update'
|
||||||
|
| 'broadcast_all'
|
||||||
|
| 'broadcast_marathon'
|
||||||
|
| 'admin_login'
|
||||||
|
| 'admin_2fa_success'
|
||||||
|
| 'admin_2fa_fail'
|
||||||
|
|
||||||
|
export interface AdminLog {
|
||||||
|
id: number
|
||||||
|
admin_id: number
|
||||||
|
admin_nickname: string
|
||||||
|
action: AdminActionType
|
||||||
|
target_type: string
|
||||||
|
target_id: number
|
||||||
|
details: Record<string, unknown> | null
|
||||||
|
ip_address: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminLogsResponse {
|
||||||
|
logs: AdminLog[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast types
|
||||||
|
export interface BroadcastResponse {
|
||||||
|
sent_count: number
|
||||||
|
total_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static content types
|
||||||
|
export interface StaticContent {
|
||||||
|
id: number
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
updated_at: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dashboard stats
|
||||||
|
export interface DashboardStats {
|
||||||
|
users_count: number
|
||||||
|
banned_users_count: number
|
||||||
|
marathons_count: number
|
||||||
|
active_marathons_count: number
|
||||||
|
games_count: number
|
||||||
|
total_participations: number
|
||||||
|
recent_logs: AdminLog[]
|
||||||
|
}
|
||||||
|
|
||||||
// Dispute types
|
// Dispute types
|
||||||
export type DisputeStatus = 'open' | 'valid' | 'invalid'
|
export type DisputeStatus = 'open' | 'valid' | 'invalid'
|
||||||
|
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
@@ -90,7 +90,7 @@ 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)
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT latency_ms, status, checked_at
|
SELECT latency_ms, status, checked_at
|
||||||
FROM metrics
|
FROM metrics
|
||||||
@@ -116,7 +116,7 @@ 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,
|
||||||
@@ -143,7 +143,7 @@ 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
|
||||||
@@ -249,11 +249,11 @@ def get_ssl_info(domain: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def cleanup_old_metrics(days: int = 7):
|
def cleanup_old_metrics(hours: int = 24):
|
||||||
"""Delete metrics older than specified days."""
|
"""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.isoformat(),))
|
||||||
deleted = cursor.rowcount
|
deleted = cursor.rowcount
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
@@ -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", "30"))
|
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}")
|
||||||
|
|
||||||
|
# Adaptive polling: check more frequently when issues detected
|
||||||
|
if has_issues():
|
||||||
|
await asyncio.sleep(FAST_CHECK_INTERVAL)
|
||||||
|
else:
|
||||||
await asyncio.sleep(CHECK_INTERVAL)
|
await asyncio.sleep(CHECK_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
async def periodic_cleanup():
|
async def periodic_cleanup():
|
||||||
"""Background task to cleanup old metrics (daily)."""
|
"""Background task to cleanup old metrics (runs immediately, then hourly)."""
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(86400) # 24 hours
|
|
||||||
try:
|
try:
|
||||||
deleted = cleanup_old_metrics(days=7)
|
deleted = cleanup_old_metrics(hours=24) # Keep only last 24 hours
|
||||||
|
if deleted > 0:
|
||||||
print(f"Cleaned up {deleted} old metrics")
|
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,11 +222,12 @@ 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)
|
||||||
|
if not suppress_alerts:
|
||||||
await alert_service_down(service_name, svc.display_name, message)
|
await alert_service_down(service_name, svc.display_name, message)
|
||||||
mark_incident_notified(incident_id)
|
mark_incident_notified(incident_id)
|
||||||
|
|
||||||
@@ -236,6 +238,7 @@ 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)
|
||||||
|
if not suppress_alerts:
|
||||||
await alert_service_recovered(service_name, svc.display_name, downtime_minutes)
|
await alert_service_recovered(service_name, svc.display_name, downtime_minutes)
|
||||||
|
|
||||||
async def check_all_services(
|
async def check_all_services(
|
||||||
@@ -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