10 Commits

Author SHA1 Message Date
243abe55b5 Fix service status 2025-12-20 02:28:41 +07:00
c645171671 Add static pages and styles 2025-12-20 02:01:51 +07:00
07745ea4ed Add TG banner 2025-12-20 01:07:24 +07:00
22385e8742 Fix auth refresh 2025-12-20 00:43:36 +07:00
a77a757317 Add reset password to admin panel 2025-12-20 00:34:22 +07:00
2d281d1c8c Add search and fetch user account 2025-12-20 00:17:58 +07:00
13f484e726 Fix migrations 2 2025-12-19 02:28:02 +07:00
ebaf6d39ea Fix migrations 2025-12-19 02:23:50 +07:00
481bdabaa8 Add admin panel 2025-12-19 02:07:25 +07:00
8e634994bd Add challenges promotion 2025-12-18 23:47:11 +07:00
63 changed files with 6056 additions and 677 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
],
)

View File

@@ -1,11 +1,15 @@
from datetime import datetime, timedelta
import secrets
from fastapi import APIRouter, HTTPException, status, Request from fastapi import APIRouter, HTTPException, status, Request
from sqlalchemy import select from sqlalchemy import select
from app.api.deps import DbSession, CurrentUser from app.api.deps import DbSession, CurrentUser
from app.core.security import verify_password, get_password_hash, create_access_token from app.core.security import verify_password, get_password_hash, create_access_token
from app.core.rate_limit import limiter from app.core.rate_limit import limiter
from app.models import User from app.models import User, UserRole, Admin2FASession
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate, LoginResponse
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@@ -40,7 +44,7 @@ async def register(request: Request, data: UserRegister, db: DbSession):
) )
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=LoginResponse)
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def login(request: Request, data: UserLogin, db: DbSession): async def login(request: Request, data: UserLogin, db: DbSession):
# Find user # Find user
@@ -53,6 +57,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)

View File

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

View File

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

View File

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

View File

@@ -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",
] ]

View File

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

View File

@@ -0,0 +1,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])

View File

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

View File

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

View File

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

View File

@@ -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",
] ]

View File

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

View File

@@ -1,10 +1,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

View File

@@ -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()

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -1,6 +1,8 @@
import { useEffect } from 'react'
import { Routes, Route, Navigate } from 'react-router-dom' import { Routes, Route, Navigate } from 'react-router-dom'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { ToastContainer, ConfirmModal } from '@/components/ui' import { ToastContainer, ConfirmModal } from '@/components/ui'
import { BannedScreen } from '@/components/BannedScreen'
// Layout // Layout
import { Layout } from '@/components/layout/Layout' import { Layout } from '@/components/layout/Layout'
@@ -19,10 +21,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>

View File

@@ -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
},
} }

View File

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

View File

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

View File

@@ -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
},
} }

View File

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

View File

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

View File

@@ -0,0 +1,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>
)
}

View File

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

View File

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

View File

@@ -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>
) : ( ) : (

View File

@@ -6,7 +6,7 @@ import { z } from 'zod'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { marathonsApi } from '@/api' import { marathonsApi } from '@/api'
import { NeonButton, Input, GlassCard } from '@/components/ui' import { NeonButton, Input, GlassCard } from '@/components/ui'
import { Gamepad2, LogIn, AlertCircle, Trophy, Users, Zap, Target } from 'lucide-react' import { Gamepad2, LogIn, AlertCircle, Trophy, Users, Zap, Target, Shield, ArrowLeft } from 'lucide-react'
const loginSchema = z.object({ const loginSchema = z.object({
login: z.string().min(3, 'Логин должен быть не менее 3 символов'), login: z.string().min(3, 'Логин должен быть не менее 3 символов'),
@@ -17,8 +17,9 @@ type LoginForm = z.infer<typeof loginSchema>
export function LoginPage() { export function LoginPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { login, isLoading, error, clearError, consumePendingInviteCode } = useAuthStore() const { login, verify2FA, cancel2FA, pending2FA, isLoading, error, clearError, consumePendingInviteCode } = useAuthStore()
const [submitError, setSubmitError] = useState<string | null>(null) const [submitError, setSubmitError] = useState<string | null>(null)
const [twoFACode, setTwoFACode] = useState('')
const { const {
register, register,
@@ -32,7 +33,12 @@ export function LoginPage() {
setSubmitError(null) setSubmitError(null)
clearError() clearError()
try { try {
await login(data) const result = await login(data)
// If 2FA required, don't navigate
if (result.requires2FA) {
return
}
// Check for pending invite code // Check for pending invite code
const pendingCode = consumePendingInviteCode() const pendingCode = consumePendingInviteCode()
@@ -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>

View File

@@ -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} />

View File

@@ -4,6 +4,8 @@ import { marathonsApi } from '@/api'
import type { MarathonListItem } from '@/types' import type { MarathonListItem } from '@/types'
import { NeonButton, GlassCard, StatsCard } from '@/components/ui' import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
import { Plus, Users, Calendar, Loader2, Trophy, Gamepad2, ChevronRight, Hash, Sparkles } from 'lucide-react' import { Plus, Users, Calendar, Loader2, Trophy, Gamepad2, ChevronRight, Hash, Sparkles } from 'lucide-react'
import { TelegramBotBanner } from '@/components/TelegramBotBanner'
import { AnnouncementBanner } from '@/components/AnnouncementBanner'
import { format } from 'date-fns' import { format } from 'date-fns'
import { ru } from 'date-fns/locale' import { ru } from 'date-fns/locale'
@@ -145,6 +147,16 @@ export function MarathonsPage() {
</div> </div>
)} )}
{/* Announcement Banner */}
<div className="mb-4">
<AnnouncementBanner />
</div>
{/* Telegram Bot Banner */}
<div className="mb-8">
<TelegramBotBanner />
</div>
{/* Join marathon */} {/* Join marathon */}
{showJoinSection && ( {showJoinSection && (
<GlassCard className="mb-8 animate-slide-in-down" variant="neon"> <GlassCard className="mb-8 animate-slide-in-down" variant="neon">

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>
)
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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'

View File

@@ -3,6 +3,23 @@ import { persist } from 'zustand/middleware'
import type { User } from '@/types' import type { User } from '@/types'
import { authApi, type RegisterData, type LoginData } from '@/api/auth' import { authApi, type RegisterData, type LoginData } from '@/api/auth'
let syncPromise: Promise<void> | null = null
interface Pending2FA {
sessionId: number
}
interface LoginResult {
requires2FA: boolean
sessionId?: number
}
export interface BanInfo {
banned_at: string | null
banned_until: string | null
reason: string | null
}
interface AuthState { interface AuthState {
user: User | null user: User | null
token: string | null token: string | null
@@ -11,8 +28,12 @@ interface AuthState {
error: string | null error: string | null
pendingInviteCode: string | null pendingInviteCode: string | null
avatarVersion: number avatarVersion: number
pending2FA: Pending2FA | null
banInfo: BanInfo | null
login: (data: LoginData) => Promise<void> login: (data: LoginData) => Promise<LoginResult>
verify2FA: (code: string) => Promise<void>
cancel2FA: () => void
register: (data: RegisterData) => Promise<void> register: (data: RegisterData) => Promise<void>
logout: () => void logout: () => void
clearError: () => void clearError: () => void
@@ -20,6 +41,9 @@ interface AuthState {
consumePendingInviteCode: () => string | null consumePendingInviteCode: () => string | null
updateUser: (updates: Partial<User>) => void updateUser: (updates: Partial<User>) => void
bumpAvatarVersion: () => void bumpAvatarVersion: () => void
setBanned: (banInfo: BanInfo) => void
clearBanned: () => void
syncUser: () => Promise<void>
} }
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
@@ -32,11 +56,25 @@ export const useAuthStore = create<AuthState>()(
error: null, error: null,
pendingInviteCode: null, pendingInviteCode: null,
avatarVersion: 0, avatarVersion: 0,
pending2FA: null,
banInfo: null,
login: async (data) => { login: async (data) => {
set({ isLoading: true, error: null }) set({ isLoading: true, error: null, pending2FA: null })
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',

View File

@@ -26,6 +26,15 @@ export interface TokenResponse {
user: User user: User
} }
// Login response (may require 2FA for admins)
export interface LoginResponse {
access_token?: string | null
token_type: string
user?: User | null
requires_2fa: boolean
two_factor_session_id?: number | null
}
// Marathon types // Marathon types
export type MarathonStatus = 'preparing' | 'active' | 'finished' export type MarathonStatus = 'preparing' | 'active' | 'finished'
export type ParticipantRole = 'participant' | 'organizer' export type ParticipantRole = 'participant' | 'organizer'
@@ -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'

View File

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

View File

@@ -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()

View File

@@ -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()
} }

View File

@@ -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:

View File

@@ -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 %}
&bull; Auto-refresh every {{ check_interval }}s &bull; 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>