Compare commits
22 Commits
11f7b59471
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 243abe55b5 | |||
| c645171671 | |||
| 07745ea4ed | |||
| 22385e8742 | |||
| a77a757317 | |||
| 2d281d1c8c | |||
| 13f484e726 | |||
| ebaf6d39ea | |||
| 481bdabaa8 | |||
| 8e634994bd | |||
| 33f49f4e47 | |||
| 57bad3b4a8 | |||
| e43e579329 | |||
| 967176fab8 | |||
| f371178518 | |||
| 3920a9bf8c | |||
| 790b2d6083 | |||
| 675a0fea0c | |||
| 0b3837b08e | |||
| 7e7cdbcd76 | |||
| debdd66458 | |||
| 332491454d |
10
.env.example
10
.env.example
@@ -10,6 +10,7 @@ OPENAI_API_KEY=sk-...
|
|||||||
|
|
||||||
# Telegram Bot
|
# Telegram Bot
|
||||||
TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
|
TELEGRAM_BOT_TOKEN=123456:ABC-DEF...
|
||||||
|
BOT_API_SECRET=change_me_random_secret_for_bot_api
|
||||||
|
|
||||||
# S3 Storage - FirstVDS (set S3_ENABLED=true to use)
|
# S3 Storage - FirstVDS (set S3_ENABLED=true to use)
|
||||||
S3_ENABLED=false
|
S3_ENABLED=false
|
||||||
@@ -20,5 +21,14 @@ S3_SECRET_ACCESS_KEY=your-secret-access-key
|
|||||||
S3_ENDPOINT_URL=https://s3.firstvds.ru
|
S3_ENDPOINT_URL=https://s3.firstvds.ru
|
||||||
S3_PUBLIC_URL=https://your-bucket-name.s3.firstvds.ru
|
S3_PUBLIC_URL=https://your-bucket-name.s3.firstvds.ru
|
||||||
|
|
||||||
|
# Backup Service
|
||||||
|
TELEGRAM_ADMIN_ID=947392854
|
||||||
|
S3_BACKUP_PREFIX=backups/
|
||||||
|
BACKUP_RETENTION_DAYS=14
|
||||||
|
|
||||||
|
# Status Service (optional - for external monitoring)
|
||||||
|
EXTERNAL_URL=https://your-domain.com
|
||||||
|
PUBLIC_URL=https://your-domain.com
|
||||||
|
|
||||||
# Frontend (for build)
|
# Frontend (for build)
|
||||||
VITE_API_URL=/api/v1
|
VITE_API_URL=/api/v1
|
||||||
|
|||||||
23
Makefile
23
Makefile
@@ -31,6 +31,12 @@ help:
|
|||||||
@echo " make shell - Open backend shell"
|
@echo " make shell - Open backend shell"
|
||||||
@echo " make frontend-sh - Open frontend shell"
|
@echo " make frontend-sh - Open frontend shell"
|
||||||
@echo ""
|
@echo ""
|
||||||
|
@echo " Backup:"
|
||||||
|
@echo " make backup-now - Run backup immediately"
|
||||||
|
@echo " make backup-list - List available backups in S3"
|
||||||
|
@echo " make backup-restore - Restore from backup (interactive)"
|
||||||
|
@echo " make backup-logs - Show backup service logs"
|
||||||
|
@echo ""
|
||||||
@echo " Cleanup:"
|
@echo " Cleanup:"
|
||||||
@echo " make clean - Stop and remove containers, volumes"
|
@echo " make clean - Stop and remove containers, volumes"
|
||||||
@echo " make prune - Remove unused Docker resources"
|
@echo " make prune - Remove unused Docker resources"
|
||||||
@@ -137,3 +143,20 @@ test-backend:
|
|||||||
# Production
|
# Production
|
||||||
prod:
|
prod:
|
||||||
$(DC) -f docker-compose.yml up -d --build
|
$(DC) -f docker-compose.yml up -d --build
|
||||||
|
|
||||||
|
# Backup
|
||||||
|
backup-now:
|
||||||
|
$(DC) exec backup python /app/backup.py
|
||||||
|
|
||||||
|
backup-list:
|
||||||
|
$(DC) exec backup python /app/restore.py
|
||||||
|
|
||||||
|
backup-restore:
|
||||||
|
@read -p "Backup filename: " file; \
|
||||||
|
$(DC) exec -it backup python /app/restore.py "$$file"
|
||||||
|
|
||||||
|
backup-logs:
|
||||||
|
$(DC) logs -f backup
|
||||||
|
|
||||||
|
backup-shell:
|
||||||
|
$(DC) exec backup bash
|
||||||
|
|||||||
43
REDESIGN_PLAN.md
Normal file
43
REDESIGN_PLAN.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# План редизайна фронтенда Game Marathon
|
||||||
|
|
||||||
|
## Концепция дизайна
|
||||||
|
|
||||||
|
**Стиль:** Минималистичный геймерский дизайн
|
||||||
|
- Темная база с неоновыми акцентами (cyan/purple/pink градиенты)
|
||||||
|
- Glitch эффекты на заголовках и при hover
|
||||||
|
- Glassmorphism для карточек (blur + transparency)
|
||||||
|
- Subtle grain/noise текстура на фоне
|
||||||
|
- Геометрические паттерны и линии
|
||||||
|
- Микро-анимации везде
|
||||||
|
|
||||||
|
**Цветовая палитра:**
|
||||||
|
```
|
||||||
|
Background: #0a0a0f (почти черный с синим оттенком)
|
||||||
|
Surface: #12121a (карточки)
|
||||||
|
Border: #1e1e2e (границы)
|
||||||
|
Primary: #00f0ff (cyan неон)
|
||||||
|
Secondary: #a855f7 (purple)
|
||||||
|
Accent: #f0abfc (pink)
|
||||||
|
Success: #22c55e
|
||||||
|
Error: #ef4444
|
||||||
|
Text: #e2e8f0
|
||||||
|
Text Muted: #64748b
|
||||||
|
```А ты
|
||||||
|
|
||||||
|
## Референсы для вдохновления
|
||||||
|
|
||||||
|
- Cyberpunk 2077 UI
|
||||||
|
- Discord dark theme
|
||||||
|
- Valorant game UI
|
||||||
|
- Steam profile pages
|
||||||
|
- Twitch streaming UI
|
||||||
|
- Epic Games Store
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Технические заметки
|
||||||
|
|
||||||
|
**Framer Motion:** Использовать для page transitions и сложных анимаций
|
||||||
|
**CSS:** Использовать для простых transitions и hover эффектов
|
||||||
|
**Tailwind:** Основной инструмент для стилей
|
||||||
|
**Custom Hooks:** useAnimation, useGlitch для переиспользования логики
|
||||||
@@ -9,6 +9,7 @@ from typing import Sequence, Union
|
|||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '001_add_roles'
|
revision: str = '001_add_roles'
|
||||||
@@ -17,17 +18,35 @@ branch_labels: Union[str, Sequence[str], None] = None
|
|||||||
depends_on: Union[str, Sequence[str], None] = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def constraint_exists(table_name: str, constraint_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
fks = inspector.get_foreign_keys(table_name)
|
||||||
|
return any(fk['name'] == constraint_name for fk in fks)
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# Add role column to users table
|
# Add role column to users table
|
||||||
|
if not column_exists('users', 'role'):
|
||||||
op.add_column('users', sa.Column('role', sa.String(20), nullable=False, server_default='user'))
|
op.add_column('users', sa.Column('role', sa.String(20), nullable=False, server_default='user'))
|
||||||
|
|
||||||
# Add role column to participants table
|
# Add role column to participants table
|
||||||
|
if not column_exists('participants', 'role'):
|
||||||
op.add_column('participants', sa.Column('role', sa.String(20), nullable=False, server_default='participant'))
|
op.add_column('participants', sa.Column('role', sa.String(20), nullable=False, server_default='participant'))
|
||||||
|
|
||||||
# Rename organizer_id to creator_id in marathons table
|
# Rename organizer_id to creator_id in marathons table
|
||||||
|
if column_exists('marathons', 'organizer_id') and not column_exists('marathons', 'creator_id'):
|
||||||
op.alter_column('marathons', 'organizer_id', new_column_name='creator_id')
|
op.alter_column('marathons', 'organizer_id', new_column_name='creator_id')
|
||||||
|
|
||||||
# Update existing participants: set role='organizer' for marathon creators
|
# Update existing participants: set role='organizer' for marathon creators
|
||||||
|
# This is idempotent - running multiple times is safe
|
||||||
op.execute("""
|
op.execute("""
|
||||||
UPDATE participants p
|
UPDATE participants p
|
||||||
SET role = 'organizer'
|
SET role = 'organizer'
|
||||||
@@ -36,13 +55,17 @@ def upgrade() -> None:
|
|||||||
""")
|
""")
|
||||||
|
|
||||||
# Add status column to games table
|
# Add status column to games table
|
||||||
|
if not column_exists('games', 'status'):
|
||||||
op.add_column('games', sa.Column('status', sa.String(20), nullable=False, server_default='approved'))
|
op.add_column('games', sa.Column('status', sa.String(20), nullable=False, server_default='approved'))
|
||||||
|
|
||||||
# Rename added_by_id to proposed_by_id in games table
|
# Rename added_by_id to proposed_by_id in games table
|
||||||
|
if column_exists('games', 'added_by_id') and not column_exists('games', 'proposed_by_id'):
|
||||||
op.alter_column('games', 'added_by_id', new_column_name='proposed_by_id')
|
op.alter_column('games', 'added_by_id', new_column_name='proposed_by_id')
|
||||||
|
|
||||||
# Add approved_by_id column to games table
|
# Add approved_by_id column to games table
|
||||||
|
if not column_exists('games', 'approved_by_id'):
|
||||||
op.add_column('games', sa.Column('approved_by_id', sa.Integer(), nullable=True))
|
op.add_column('games', sa.Column('approved_by_id', sa.Integer(), nullable=True))
|
||||||
|
if not constraint_exists('games', 'fk_games_approved_by_id'):
|
||||||
op.create_foreign_key(
|
op.create_foreign_key(
|
||||||
'fk_games_approved_by_id',
|
'fk_games_approved_by_id',
|
||||||
'games', 'users',
|
'games', 'users',
|
||||||
@@ -53,20 +76,27 @@ def upgrade() -> None:
|
|||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
# Remove approved_by_id from games
|
# Remove approved_by_id from games
|
||||||
|
if constraint_exists('games', 'fk_games_approved_by_id'):
|
||||||
op.drop_constraint('fk_games_approved_by_id', 'games', type_='foreignkey')
|
op.drop_constraint('fk_games_approved_by_id', 'games', type_='foreignkey')
|
||||||
|
if column_exists('games', 'approved_by_id'):
|
||||||
op.drop_column('games', 'approved_by_id')
|
op.drop_column('games', 'approved_by_id')
|
||||||
|
|
||||||
# Rename proposed_by_id back to added_by_id
|
# Rename proposed_by_id back to added_by_id
|
||||||
|
if column_exists('games', 'proposed_by_id'):
|
||||||
op.alter_column('games', 'proposed_by_id', new_column_name='added_by_id')
|
op.alter_column('games', 'proposed_by_id', new_column_name='added_by_id')
|
||||||
|
|
||||||
# Remove status from games
|
# Remove status from games
|
||||||
|
if column_exists('games', 'status'):
|
||||||
op.drop_column('games', 'status')
|
op.drop_column('games', 'status')
|
||||||
|
|
||||||
# Rename creator_id back to organizer_id
|
# Rename creator_id back to organizer_id
|
||||||
|
if column_exists('marathons', 'creator_id'):
|
||||||
op.alter_column('marathons', 'creator_id', new_column_name='organizer_id')
|
op.alter_column('marathons', 'creator_id', new_column_name='organizer_id')
|
||||||
|
|
||||||
# Remove role from participants
|
# Remove role from participants
|
||||||
|
if column_exists('participants', 'role'):
|
||||||
op.drop_column('participants', 'role')
|
op.drop_column('participants', 'role')
|
||||||
|
|
||||||
# Remove role from users
|
# Remove role from users
|
||||||
|
if column_exists('users', 'role'):
|
||||||
op.drop_column('users', 'role')
|
op.drop_column('users', 'role')
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Sequence, Union
|
|||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = '002_marathon_settings'
|
revision: str = '002_marathon_settings'
|
||||||
@@ -17,16 +18,27 @@ branch_labels: Union[str, Sequence[str], None] = None
|
|||||||
depends_on: Union[str, Sequence[str], None] = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# Add is_public column to marathons table (default False = private)
|
# Add is_public column to marathons table (default False = private)
|
||||||
|
if not column_exists('marathons', 'is_public'):
|
||||||
op.add_column('marathons', sa.Column('is_public', sa.Boolean(), nullable=False, server_default='false'))
|
op.add_column('marathons', sa.Column('is_public', sa.Boolean(), nullable=False, server_default='false'))
|
||||||
|
|
||||||
# Add game_proposal_mode column to marathons table
|
# Add game_proposal_mode column to marathons table
|
||||||
# 'all_participants' - anyone can propose games (with moderation)
|
# 'all_participants' - anyone can propose games (with moderation)
|
||||||
# 'organizer_only' - only organizers can add games
|
# 'organizer_only' - only organizers can add games
|
||||||
|
if not column_exists('marathons', 'game_proposal_mode'):
|
||||||
op.add_column('marathons', sa.Column('game_proposal_mode', sa.String(20), nullable=False, server_default='all_participants'))
|
op.add_column('marathons', sa.Column('game_proposal_mode', sa.String(20), nullable=False, server_default='all_participants'))
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
if column_exists('marathons', 'game_proposal_mode'):
|
||||||
op.drop_column('marathons', 'game_proposal_mode')
|
op.drop_column('marathons', 'game_proposal_mode')
|
||||||
|
if column_exists('marathons', 'is_public'):
|
||||||
op.drop_column('marathons', 'is_public')
|
op.drop_column('marathons', 'is_public')
|
||||||
|
|||||||
@@ -17,15 +17,17 @@ depends_on = None
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
# Update event type from 'rematch' to 'game_choice' in events table
|
# Update event type from 'rematch' to 'game_choice' in events table
|
||||||
|
# These UPDATE statements are idempotent - safe to run multiple times
|
||||||
op.execute("UPDATE events SET type = 'game_choice' WHERE type = 'rematch'")
|
op.execute("UPDATE events SET type = 'game_choice' WHERE type = 'rematch'")
|
||||||
|
|
||||||
# Update event_type in assignments table
|
# Update event_type in assignments table
|
||||||
op.execute("UPDATE assignments SET event_type = 'game_choice' WHERE event_type = 'rematch'")
|
op.execute("UPDATE assignments SET event_type = 'game_choice' WHERE event_type = 'rematch'")
|
||||||
|
|
||||||
# Update activity data that references rematch event
|
# Update activity data that references rematch event
|
||||||
|
# Cast JSON to JSONB, apply jsonb_set, then cast back to JSON
|
||||||
op.execute("""
|
op.execute("""
|
||||||
UPDATE activities
|
UPDATE activities
|
||||||
SET data = jsonb_set(data, '{event_type}', '"game_choice"')
|
SET data = jsonb_set(data::jsonb, '{event_type}', '"game_choice"')::json
|
||||||
WHERE data->>'event_type' = 'rematch'
|
WHERE data->>'event_type' = 'rematch'
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -36,6 +38,6 @@ def downgrade() -> None:
|
|||||||
op.execute("UPDATE assignments SET event_type = 'rematch' WHERE event_type = 'game_choice'")
|
op.execute("UPDATE assignments SET event_type = 'rematch' WHERE event_type = 'game_choice'")
|
||||||
op.execute("""
|
op.execute("""
|
||||||
UPDATE activities
|
UPDATE activities
|
||||||
SET data = jsonb_set(data, '{event_type}', '"rematch"')
|
SET data = jsonb_set(data::jsonb, '{event_type}', '"rematch"')::json
|
||||||
WHERE data->>'event_type' = 'game_choice'
|
WHERE data->>'event_type' = 'game_choice'
|
||||||
""")
|
""")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Sequence, Union
|
|||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
@@ -18,13 +19,26 @@ branch_labels: Union[str, Sequence[str], None] = None
|
|||||||
depends_on: Union[str, Sequence[str], None] = None
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
|
if not column_exists('users', 'telegram_first_name'):
|
||||||
op.add_column('users', sa.Column('telegram_first_name', sa.String(100), nullable=True))
|
op.add_column('users', sa.Column('telegram_first_name', sa.String(100), nullable=True))
|
||||||
|
if not column_exists('users', 'telegram_last_name'):
|
||||||
op.add_column('users', sa.Column('telegram_last_name', sa.String(100), nullable=True))
|
op.add_column('users', sa.Column('telegram_last_name', sa.String(100), nullable=True))
|
||||||
|
if not column_exists('users', 'telegram_avatar_url'):
|
||||||
op.add_column('users', sa.Column('telegram_avatar_url', sa.String(500), nullable=True))
|
op.add_column('users', sa.Column('telegram_avatar_url', sa.String(500), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
if column_exists('users', 'telegram_avatar_url'):
|
||||||
op.drop_column('users', 'telegram_avatar_url')
|
op.drop_column('users', 'telegram_avatar_url')
|
||||||
|
if column_exists('users', 'telegram_last_name'):
|
||||||
op.drop_column('users', 'telegram_last_name')
|
op.drop_column('users', 'telegram_last_name')
|
||||||
|
if column_exists('users', 'telegram_first_name'):
|
||||||
op.drop_column('users', 'telegram_first_name')
|
op.drop_column('users', 'telegram_first_name')
|
||||||
|
|||||||
40
backend/alembic/versions/011_add_challenge_proposals.py
Normal file
40
backend/alembic/versions/011_add_challenge_proposals.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""Add challenge proposals support
|
||||||
|
|
||||||
|
Revision ID: 011_add_challenge_proposals
|
||||||
|
Revises: 010_add_telegram_profile
|
||||||
|
Create Date: 2024-12-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '011_add_challenge_proposals'
|
||||||
|
down_revision: Union[str, None] = '010_add_telegram_profile'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not column_exists('challenges', 'proposed_by_id'):
|
||||||
|
op.add_column('challenges', sa.Column('proposed_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True))
|
||||||
|
if not column_exists('challenges', 'status'):
|
||||||
|
op.add_column('challenges', sa.Column('status', sa.String(20), server_default='approved', nullable=False))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if column_exists('challenges', 'status'):
|
||||||
|
op.drop_column('challenges', 'status')
|
||||||
|
if column_exists('challenges', 'proposed_by_id'):
|
||||||
|
op.drop_column('challenges', 'proposed_by_id')
|
||||||
48
backend/alembic/versions/012_add_user_banned.py
Normal file
48
backend/alembic/versions/012_add_user_banned.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""Add user banned fields
|
||||||
|
|
||||||
|
Revision ID: 012_add_user_banned
|
||||||
|
Revises: 011_add_challenge_proposals
|
||||||
|
Create Date: 2024-12-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '012_add_user_banned'
|
||||||
|
down_revision: Union[str, None] = '011_add_challenge_proposals'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not column_exists('users', 'is_banned'):
|
||||||
|
op.add_column('users', sa.Column('is_banned', sa.Boolean(), server_default='false', nullable=False))
|
||||||
|
if not column_exists('users', 'banned_at'):
|
||||||
|
op.add_column('users', sa.Column('banned_at', sa.DateTime(), nullable=True))
|
||||||
|
if not column_exists('users', 'banned_by_id'):
|
||||||
|
op.add_column('users', sa.Column('banned_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True))
|
||||||
|
if not column_exists('users', 'ban_reason'):
|
||||||
|
op.add_column('users', sa.Column('ban_reason', sa.String(500), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if column_exists('users', 'ban_reason'):
|
||||||
|
op.drop_column('users', 'ban_reason')
|
||||||
|
if column_exists('users', 'banned_by_id'):
|
||||||
|
op.drop_column('users', 'banned_by_id')
|
||||||
|
if column_exists('users', 'banned_at'):
|
||||||
|
op.drop_column('users', 'banned_at')
|
||||||
|
if column_exists('users', 'is_banned'):
|
||||||
|
op.drop_column('users', 'is_banned')
|
||||||
61
backend/alembic/versions/013_add_admin_logs.py
Normal file
61
backend/alembic/versions/013_add_admin_logs.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Add admin_logs table
|
||||||
|
|
||||||
|
Revision ID: 013_add_admin_logs
|
||||||
|
Revises: 012_add_user_banned
|
||||||
|
Create Date: 2024-12-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '013_add_admin_logs'
|
||||||
|
down_revision: Union[str, None] = '012_add_user_banned'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
indexes = inspector.get_indexes(table_name)
|
||||||
|
return any(idx['name'] == index_name for idx in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not table_exists('admin_logs'):
|
||||||
|
op.create_table(
|
||||||
|
'admin_logs',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('admin_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||||
|
sa.Column('action', sa.String(50), nullable=False),
|
||||||
|
sa.Column('target_type', sa.String(50), nullable=False),
|
||||||
|
sa.Column('target_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('details', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('ip_address', sa.String(50), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not index_exists('admin_logs', 'ix_admin_logs_admin_id'):
|
||||||
|
op.create_index('ix_admin_logs_admin_id', 'admin_logs', ['admin_id'])
|
||||||
|
if not index_exists('admin_logs', 'ix_admin_logs_action'):
|
||||||
|
op.create_index('ix_admin_logs_action', 'admin_logs', ['action'])
|
||||||
|
if not index_exists('admin_logs', 'ix_admin_logs_created_at'):
|
||||||
|
op.create_index('ix_admin_logs_created_at', 'admin_logs', ['created_at'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_admin_logs_created_at', 'admin_logs')
|
||||||
|
op.drop_index('ix_admin_logs_action', 'admin_logs')
|
||||||
|
op.drop_index('ix_admin_logs_admin_id', 'admin_logs')
|
||||||
|
op.drop_table('admin_logs')
|
||||||
57
backend/alembic/versions/014_add_admin_2fa.py
Normal file
57
backend/alembic/versions/014_add_admin_2fa.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Add admin_2fa_sessions table
|
||||||
|
|
||||||
|
Revision ID: 014_add_admin_2fa
|
||||||
|
Revises: 013_add_admin_logs
|
||||||
|
Create Date: 2024-12-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '014_add_admin_2fa'
|
||||||
|
down_revision: Union[str, None] = '013_add_admin_logs'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
indexes = inspector.get_indexes(table_name)
|
||||||
|
return any(idx['name'] == index_name for idx in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not table_exists('admin_2fa_sessions'):
|
||||||
|
op.create_table(
|
||||||
|
'admin_2fa_sessions',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
|
||||||
|
sa.Column('code', sa.String(6), nullable=False),
|
||||||
|
sa.Column('telegram_sent', sa.Boolean(), server_default='false', nullable=False),
|
||||||
|
sa.Column('is_verified', sa.Boolean(), server_default='false', nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not index_exists('admin_2fa_sessions', 'ix_admin_2fa_sessions_user_id'):
|
||||||
|
op.create_index('ix_admin_2fa_sessions_user_id', 'admin_2fa_sessions', ['user_id'])
|
||||||
|
if not index_exists('admin_2fa_sessions', 'ix_admin_2fa_sessions_expires_at'):
|
||||||
|
op.create_index('ix_admin_2fa_sessions_expires_at', 'admin_2fa_sessions', ['expires_at'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_admin_2fa_sessions_expires_at', 'admin_2fa_sessions')
|
||||||
|
op.drop_index('ix_admin_2fa_sessions_user_id', 'admin_2fa_sessions')
|
||||||
|
op.drop_table('admin_2fa_sessions')
|
||||||
54
backend/alembic/versions/015_add_static_content.py
Normal file
54
backend/alembic/versions/015_add_static_content.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Add static_content table
|
||||||
|
|
||||||
|
Revision ID: 015_add_static_content
|
||||||
|
Revises: 014_add_admin_2fa
|
||||||
|
Create Date: 2024-12-18
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '015_add_static_content'
|
||||||
|
down_revision: Union[str, None] = '014_add_admin_2fa'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
indexes = inspector.get_indexes(table_name)
|
||||||
|
return any(idx['name'] == index_name for idx in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not table_exists('static_content'):
|
||||||
|
op.create_table(
|
||||||
|
'static_content',
|
||||||
|
sa.Column('id', sa.Integer(), primary_key=True),
|
||||||
|
sa.Column('key', sa.String(100), unique=True, nullable=False),
|
||||||
|
sa.Column('title', sa.String(200), nullable=False),
|
||||||
|
sa.Column('content', sa.Text(), nullable=False),
|
||||||
|
sa.Column('updated_by_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not index_exists('static_content', 'ix_static_content_key'):
|
||||||
|
op.create_index('ix_static_content_key', 'static_content', ['key'], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_static_content_key', 'static_content')
|
||||||
|
op.drop_table('static_content')
|
||||||
36
backend/alembic/versions/016_add_banned_until.py
Normal file
36
backend/alembic/versions/016_add_banned_until.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""Add banned_until field
|
||||||
|
|
||||||
|
Revision ID: 016_add_banned_until
|
||||||
|
Revises: 015_add_static_content
|
||||||
|
Create Date: 2024-12-19
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '016_add_banned_until'
|
||||||
|
down_revision: Union[str, None] = '015_add_static_content'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not column_exists('users', 'banned_until'):
|
||||||
|
op.add_column('users', sa.Column('banned_until', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if column_exists('users', 'banned_until'):
|
||||||
|
op.drop_column('users', 'banned_until')
|
||||||
47
backend/alembic/versions/017_admin_logs_nullable_admin_id.py
Normal file
47
backend/alembic/versions/017_admin_logs_nullable_admin_id.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Make admin_id nullable in admin_logs for system actions
|
||||||
|
|
||||||
|
Revision ID: 017_admin_logs_nullable_admin_id
|
||||||
|
Revises: 016_add_banned_until
|
||||||
|
Create Date: 2024-12-19
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '017_admin_logs_nullable_admin_id'
|
||||||
|
down_revision: Union[str, None] = '016_add_banned_until'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def is_column_nullable(table_name: str, column_name: str) -> bool:
|
||||||
|
"""Check if a column is nullable."""
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = inspector.get_columns(table_name)
|
||||||
|
for col in columns:
|
||||||
|
if col['name'] == column_name:
|
||||||
|
return col.get('nullable', True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Make admin_id nullable for system actions (like auto-unban)
|
||||||
|
# Only alter if currently not nullable
|
||||||
|
if not is_column_nullable('admin_logs', 'admin_id'):
|
||||||
|
op.alter_column('admin_logs', 'admin_id',
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Revert to not nullable (will fail if there are NULL values)
|
||||||
|
if is_column_nullable('admin_logs', 'admin_id'):
|
||||||
|
op.alter_column('admin_logs', 'admin_id',
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
nullable=False)
|
||||||
346
backend/alembic/versions/018_seed_static_content.py
Normal file
346
backend/alembic/versions/018_seed_static_content.py
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
"""Seed static content
|
||||||
|
|
||||||
|
Revision ID: 018_seed_static_content
|
||||||
|
Revises: 017_admin_logs_nullable_admin_id
|
||||||
|
Create Date: 2024-12-20
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '018_seed_static_content'
|
||||||
|
down_revision: Union[str, None] = '017_admin_logs_nullable_admin_id'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
STATIC_CONTENT_DATA = [
|
||||||
|
{
|
||||||
|
'key': 'terms_of_service',
|
||||||
|
'title': 'Пользовательское соглашение',
|
||||||
|
'content': '''<p class="text-gray-400 mb-6">Настоящее Пользовательское соглашение (далее — «Соглашение») регулирует отношения между администрацией интернет-сервиса «Игровой Марафон» (далее — «Сервис», «Платформа», «Мы») и физическим лицом, использующим Сервис (далее — «Пользователь», «Вы»).</p>
|
||||||
|
|
||||||
|
<p class="text-gray-400 mb-6"><strong class="text-white">Дата вступления в силу:</strong> с момента регистрации на Платформе.<br/>
|
||||||
|
Используя Сервис, Вы подтверждаете, что полностью ознакомились с условиями настоящего Соглашения и принимаете их в полном объёме.</p>
|
||||||
|
|
||||||
|
<hr class="my-8 border-dark-600" />
|
||||||
|
|
||||||
|
<h2>1. Общие положения</h2>
|
||||||
|
|
||||||
|
<p>1.1. Сервис «Игровой Марафон» представляет собой онлайн-платформу для организации и проведения игровых марафонов — соревнований, в рамках которых участники выполняют игровые задания (челленджи) и получают очки за их успешное выполнение.</p>
|
||||||
|
|
||||||
|
<p>1.2. Сервис предоставляет Пользователям следующие возможности:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Создание и участие в игровых марафонах</li>
|
||||||
|
<li>Получение случайных игровых заданий различной сложности</li>
|
||||||
|
<li>Отслеживание прогресса и статистики участников</li>
|
||||||
|
<li>Участие в специальных игровых событиях</li>
|
||||||
|
<li>Получение уведомлений через интеграцию с Telegram</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>1.3. Сервис предоставляется на условиях «как есть» (as is). Администрация не гарантирует, что Сервис будет соответствовать ожиданиям Пользователя или работать бесперебойно.</p>
|
||||||
|
|
||||||
|
<hr class="my-8 border-dark-600" />
|
||||||
|
|
||||||
|
<h2>2. Регистрация и учётная запись</h2>
|
||||||
|
|
||||||
|
<p>2.1. Для доступа к функционалу Сервиса необходима регистрация учётной записи. При регистрации Пользователь обязуется предоставить достоверные данные.</p>
|
||||||
|
|
||||||
|
<p>2.2. Пользователь несёт полную ответственность за:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Сохранность своих учётных данных (логина и пароля)</li>
|
||||||
|
<li>Все действия, совершённые с использованием его учётной записи</li>
|
||||||
|
<li>Своевременное уведомление Администрации о несанкционированном доступе к аккаунту</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>2.3. Каждый Пользователь имеет право на одну учётную запись. Создание дополнительных аккаунтов (мультиаккаунтинг) запрещено и влечёт блокировку всех связанных учётных записей.</p>
|
||||||
|
|
||||||
|
<p>2.4. Пользователь вправе в любой момент удалить свою учётную запись, обратившись к Администрации. При удалении аккаунта все связанные данные будут безвозвратно удалены.</p>
|
||||||
|
|
||||||
|
<hr class="my-8 border-dark-600" />
|
||||||
|
|
||||||
|
<h2>3. Правила использования Сервиса</h2>
|
||||||
|
|
||||||
|
<p>3.1. <strong class="text-white">При использовании Сервиса запрещается:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Использовать читы, эксплойты, модификации и любое стороннее программное обеспечение, дающее нечестное преимущество при выполнении игровых заданий</li>
|
||||||
|
<li>Предоставлять ложные доказательства выполнения заданий (поддельные скриншоты, видео, достижения)</li>
|
||||||
|
<li>Передавать доступ к учётной записи третьим лицам</li>
|
||||||
|
<li>Оскорблять, унижать или преследовать других участников</li>
|
||||||
|
<li>Распространять спам, рекламу или вредоносный контент</li>
|
||||||
|
<li>Нарушать работу Сервиса техническими средствами</li>
|
||||||
|
<li>Использовать Сервис для деятельности, нарушающей законодательство</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>3.2. <strong class="text-white">Правила проведения марафонов:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Участники обязаны честно выполнять полученные задания</li>
|
||||||
|
<li>Доказательства выполнения должны быть подлинными и соответствовать требованиям задания</li>
|
||||||
|
<li>Отказ от задания (дроп) влечёт штрафные санкции согласно правилам конкретного марафона</li>
|
||||||
|
<li>Споры по заданиям разрешаются через систему диспутов с участием других участников марафона</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>3.3. Организаторы марафонов несут ответственность за соблюдение правил в рамках своих мероприятий и имеют право устанавливать дополнительные правила, не противоречащие настоящему Соглашению.</p>
|
||||||
|
|
||||||
|
<hr class="my-8 border-dark-600" />
|
||||||
|
|
||||||
|
<h2>4. Система очков и рейтинг</h2>
|
||||||
|
|
||||||
|
<p>4.1. За выполнение заданий Пользователи получают очки, количество которых зависит от сложности задания и активных игровых событий.</p>
|
||||||
|
|
||||||
|
<p>4.2. Очки используются исключительно для формирования рейтинга участников в рамках марафонов и не имеют денежного эквивалента.</p>
|
||||||
|
|
||||||
|
<p>4.3. Администрация оставляет за собой право корректировать начисленные очки в случае выявления нарушений или технических ошибок.</p>
|
||||||
|
|
||||||
|
<hr class="my-8 border-dark-600" />
|
||||||
|
|
||||||
|
<h2>5. Ответственность сторон</h2>
|
||||||
|
|
||||||
|
<p>5.1. <strong class="text-white">Администрация не несёт ответственности за:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Временную недоступность Сервиса по техническим причинам</li>
|
||||||
|
<li>Потерю данных вследствие технических сбоев</li>
|
||||||
|
<li>Действия третьих лиц, получивших доступ к учётной записи Пользователя</li>
|
||||||
|
<li>Контент, размещаемый Пользователями</li>
|
||||||
|
<li>Качество интернет-соединения Пользователя</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>5.2. Пользователь несёт ответственность за соблюдение условий настоящего Соглашения и применимого законодательства.</p>
|
||||||
|
|
||||||
|
<hr class="my-8 border-dark-600" />
|
||||||
|
|
||||||
|
<h2>6. Санкции за нарушения</h2>
|
||||||
|
|
||||||
|
<p>6.1. За нарушение условий настоящего Соглашения Администрация вправе применить следующие санкции:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong class="text-yellow-400">Предупреждение</strong> — за незначительные нарушения</li>
|
||||||
|
<li><strong class="text-orange-400">Временная блокировка</strong> — ограничение доступа к Сервису на определённый срок</li>
|
||||||
|
<li><strong class="text-red-400">Постоянная блокировка</strong> — бессрочное ограничение доступа за грубые или повторные нарушения</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>6.2. Решение о применении санкций принимается Администрацией единолично и является окончательным. Администрация не обязана объяснять причины принятого решения.</p>
|
||||||
|
|
||||||
|
<p>6.3. Обход блокировки путём создания новых учётных записей влечёт блокировку всех выявленных аккаунтов.</p>
|
||||||
|
|
||||||
|
<hr class="my-8 border-dark-600" />
|
||||||
|
|
||||||
|
<h2>7. Интеллектуальная собственность</h2>
|
||||||
|
|
||||||
|
<p>7.1. Все элементы Сервиса (дизайн, код, тексты, логотипы) являются объектами интеллектуальной собственности Администрации и защищены применимым законодательством.</p>
|
||||||
|
|
||||||
|
<p>7.2. Использование материалов Сервиса без письменного разрешения Администрации запрещено.</p>
|
||||||
|
|
||||||
|
<hr class="my-8 border-dark-600" />
|
||||||
|
|
||||||
|
<h2>8. Изменение условий Соглашения</h2>
|
||||||
|
|
||||||
|
<p>8.1. Администрация вправе в одностороннем порядке изменять условия настоящего Соглашения.</p>
|
||||||
|
|
||||||
|
<p>8.2. Актуальная редакция Соглашения размещается на данной странице с указанием даты последнего обновления.</p>
|
||||||
|
|
||||||
|
<p>8.3. Продолжение использования Сервиса после внесения изменений означает согласие Пользователя с новой редакцией Соглашения.</p>
|
||||||
|
|
||||||
|
<hr class="my-8 border-dark-600" />
|
||||||
|
|
||||||
|
<h2>9. Заключительные положения</h2>
|
||||||
|
|
||||||
|
<p>9.1. Настоящее Соглашение регулируется законодательством Российской Федерации.</p>
|
||||||
|
|
||||||
|
<p>9.2. Все споры, возникающие в связи с использованием Сервиса, подлежат разрешению путём переговоров. При недостижении согласия споры разрешаются в судебном порядке по месту нахождения Администрации.</p>
|
||||||
|
|
||||||
|
<p>9.3. Признание судом недействительности какого-либо положения настоящего Соглашения не влечёт недействительности остальных положений.</p>
|
||||||
|
|
||||||
|
<p>9.4. По всем вопросам, связанным с использованием Сервиса, Вы можете обратиться к Администрации через Telegram-бота или иные доступные каналы связи.</p>'''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'privacy_policy',
|
||||||
|
'title': 'Политика конфиденциальности',
|
||||||
|
'content': '''<p class="text-gray-400 mb-6">Настоящая Политика конфиденциальности (далее — «Политика») описывает, как интернет-сервис «Игровой Марафон» (далее — «Сервис», «Мы») собирает, использует, хранит и защищает персональные данные пользователей (далее — «Пользователь», «Вы»).</p>
|
||||||
|
|
||||||
|
<p class="text-gray-400 mb-6">Используя Сервис, Вы даёте согласие на обработку Ваших персональных данных в соответствии с условиями настоящей Политики.</p>
|
||||||
|
|
||||||
|
<hr class="my-8 border-dark-600" />
|
||||||
|
|
||||||
|
<h2>1. Собираемые данные</h2>
|
||||||
|
|
||||||
|
<p>1.1. <strong class="text-white">Данные, предоставляемые Пользователем:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Регистрационные данные:</strong> логин, пароль (в зашифрованном виде), никнейм</li>
|
||||||
|
<li><strong>Данные профиля:</strong> аватар (при загрузке)</li>
|
||||||
|
<li><strong>Данные интеграции Telegram:</strong> Telegram ID, имя пользователя, имя и фамилия (при привязке бота)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>1.2. <strong class="text-white">Данные, собираемые автоматически:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Данные об активности:</strong> участие в марафонах, выполненные задания, заработанные очки, статистика</li>
|
||||||
|
<li><strong>Технические данные:</strong> IP-адрес, тип браузера, время доступа (для обеспечения безопасности)</li>
|
||||||
|
<li><strong>Данные сессии:</strong> информация для поддержания авторизации</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<hr class="my-8 border-dark-600" />
|
||||||
|
|
||||||
|
<h2>2. Цели обработки данных</h2>
|
||||||
|
|
||||||
|
<p>2.1. Мы обрабатываем Ваши персональные данные для следующих целей:</p>
|
||||||
|
|
||||||
|
<p><strong class="text-neon-400">Предоставление услуг:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Идентификация и аутентификация Пользователя</li>
|
||||||
|
<li>Обеспечение участия в марафонах и игровых событиях</li>
|
||||||
|
<li>Ведение статистики и формирование рейтингов</li>
|
||||||
|
<li>Отображение профиля Пользователя другим участникам</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p><strong class="text-neon-400">Коммуникация:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Отправка уведомлений о событиях марафонов через Telegram-бота</li>
|
||||||
|
<li>Информирование о новых заданиях и результатах</li>
|
||||||
|
<li>Ответы на обращения Пользователей</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p><strong class="text-neon-400">Безопасность:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Защита от несанкционированного доступа</li>
|
||||||
|
<li>Выявление и предотвращение нарушений</li>
|
||||||
|
<li>Ведение журнала административных действий</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<hr class="my-8 border-dark-600" />
|
||||||
|
|
||||||
|
<h2>3. Правовые основания обработки</h2>
|
||||||
|
|
||||||
|
<p>3.1. Обработка персональных данных осуществляется на следующих основаниях:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Согласие Пользователя</strong> — при регистрации и использовании Сервиса</li>
|
||||||
|
<li><strong>Исполнение договора</strong> — Пользовательского соглашения</li>
|
||||||
|
<li><strong>Законный интерес</strong> — обеспечение безопасности Сервиса</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<hr class="my-8 border-dark-600" />
|
||||||
|
|
||||||
|
<h2>4. Хранение и защита данных</h2>
|
||||||
|
|
||||||
|
<p>4.1. <strong class="text-white">Меры безопасности:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Пароли хранятся в зашифрованном виде с использованием алгоритма bcrypt</li>
|
||||||
|
<li>Передача данных осуществляется по защищённому протоколу HTTPS</li>
|
||||||
|
<li>Доступ к базе данных ограничен и контролируется</li>
|
||||||
|
<li>Административные действия логируются и требуют двухфакторной аутентификации</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>4.2. <strong class="text-white">Срок хранения:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Данные учётной записи хранятся до момента её удаления Пользователем</li>
|
||||||
|
<li>Данные об активности в марафонах хранятся бессрочно для ведения статистики</li>
|
||||||
|
<li>Технические логи хранятся в течение 12 месяцев</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<hr class="my-8 border-dark-600" />
|
||||||
|
|
||||||
|
<h2>5. Передача данных третьим лицам</h2>
|
||||||
|
|
||||||
|
<p>5.1. Мы не продаём, не сдаём в аренду и не передаём Ваши персональные данные третьим лицам в коммерческих целях.</p>
|
||||||
|
|
||||||
|
<p>5.2. <strong class="text-white">Данные могут быть переданы:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Telegram — для обеспечения работы уведомлений (только Telegram ID)</li>
|
||||||
|
<li>Правоохранительным органам — по законному запросу в соответствии с применимым законодательством</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>5.3. <strong class="text-white">Публично доступная информация:</strong></p>
|
||||||
|
<p>Следующие данные видны другим Пользователям Сервиса:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Никнейм</li>
|
||||||
|
<li>Аватар</li>
|
||||||
|
<li>Статистика участия в марафонах</li>
|
||||||
|
<li>Позиция в рейтингах</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<hr class="my-8 border-dark-600" />
|
||||||
|
|
||||||
|
<h2>6. Права Пользователя</h2>
|
||||||
|
|
||||||
|
<p>6.1. Вы имеете право:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Получить доступ</strong> к своим персональным данным</li>
|
||||||
|
<li><strong>Исправить</strong> неточные или неполные данные в настройках профиля</li>
|
||||||
|
<li><strong>Удалить</strong> свою учётную запись и связанные данные</li>
|
||||||
|
<li><strong>Отозвать согласие</strong> на обработку данных (путём удаления аккаунта)</li>
|
||||||
|
<li><strong>Отключить</strong> интеграцию с Telegram в любой момент</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>6.2. Для реализации своих прав обратитесь к Администрации через доступные каналы связи.</p>
|
||||||
|
|
||||||
|
<hr class="my-8 border-dark-600" />
|
||||||
|
|
||||||
|
<h2>7. Файлы cookie и локальное хранилище</h2>
|
||||||
|
|
||||||
|
<p>7.1. Сервис использует локальное хранилище браузера (localStorage, sessionStorage) для:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Хранения токена авторизации</li>
|
||||||
|
<li>Сохранения пользовательских настроек интерфейса</li>
|
||||||
|
<li>Запоминания закрытых информационных баннеров</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>7.2. Вы можете очистить локальное хранилище в настройках браузера, однако это приведёт к выходу из учётной записи.</p>
|
||||||
|
|
||||||
|
<hr class="my-8 border-dark-600" />
|
||||||
|
|
||||||
|
<h2>8. Обработка данных несовершеннолетних</h2>
|
||||||
|
|
||||||
|
<p>8.1. Сервис не предназначен для лиц младше 14 лет. Мы сознательно не собираем персональные данные детей.</p>
|
||||||
|
|
||||||
|
<p>8.2. Если Вам стало известно, что ребёнок предоставил нам персональные данные, пожалуйста, свяжитесь с Администрацией для их удаления.</p>
|
||||||
|
|
||||||
|
<hr class="my-8 border-dark-600" />
|
||||||
|
|
||||||
|
<h2>9. Изменение Политики</h2>
|
||||||
|
|
||||||
|
<p>9.1. Мы оставляем за собой право изменять настоящую Политику. Актуальная редакция всегда доступна на данной странице.</p>
|
||||||
|
|
||||||
|
<p>9.2. О существенных изменениях мы уведомим Пользователей через Telegram-бота или баннер на сайте.</p>
|
||||||
|
|
||||||
|
<p>9.3. Продолжение использования Сервиса после внесения изменений означает согласие с обновлённой Политикой.</p>
|
||||||
|
|
||||||
|
<hr class="my-8 border-dark-600" />
|
||||||
|
|
||||||
|
<h2>10. Контактная информация</h2>
|
||||||
|
|
||||||
|
<p>10.1. По вопросам, связанным с обработкой персональных данных, Вы можете обратиться к Администрации через:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Telegram-бота Сервиса</li>
|
||||||
|
<li>Форму обратной связи (при наличии)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p>10.2. Мы обязуемся рассмотреть Ваше обращение в разумные сроки и предоставить ответ.</p>'''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'telegram_bot_info',
|
||||||
|
'title': 'Привяжите Telegram-бота',
|
||||||
|
'content': 'Получайте уведомления о событиях марафона, новых заданиях и результатах прямо в Telegram'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'announcement',
|
||||||
|
'title': 'Добро пожаловать!',
|
||||||
|
'content': 'Мы рады приветствовать вас в «Игровом Марафоне»! Создайте свой первый марафон или присоединитесь к существующему по коду приглашения.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for item in STATIC_CONTENT_DATA:
|
||||||
|
# Use ON CONFLICT to avoid duplicates
|
||||||
|
op.execute(f"""
|
||||||
|
INSERT INTO static_content (key, title, content, created_at, updated_at)
|
||||||
|
VALUES ('{item['key']}', '{item['title'].replace("'", "''")}', '{item['content'].replace("'", "''")}', NOW(), NOW())
|
||||||
|
ON CONFLICT (key) DO NOTHING
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
keys = [f"'{item['key']}'" for item in STATIC_CONTENT_DATA]
|
||||||
|
op.execute(f"DELETE FROM static_content WHERE key IN ({', '.join(keys)})")
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, status, Header
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
@@ -42,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
|
||||||
|
|
||||||
|
|
||||||
@@ -55,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,
|
||||||
@@ -145,3 +206,21 @@ async def require_creator(
|
|||||||
# Type aliases for cleaner dependency injection
|
# Type aliases for cleaner dependency injection
|
||||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
DbSession = Annotated[AsyncSession, Depends(get_db)]
|
DbSession = Annotated[AsyncSession, Depends(get_db)]
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_bot_secret(
|
||||||
|
x_bot_secret: str | None = Header(None, alias="X-Bot-Secret")
|
||||||
|
) -> None:
|
||||||
|
"""Verify that request comes from trusted bot using secret key."""
|
||||||
|
if not settings.BOT_API_SECRET:
|
||||||
|
# If secret is not configured, skip check (for development)
|
||||||
|
return
|
||||||
|
|
||||||
|
if x_bot_secret != settings.BOT_API_SECRET:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Invalid or missing bot secret"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
BotSecretDep = Annotated[None, Depends(verify_bot_secret)]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram
|
from app.api.v1 import auth, users, marathons, games, challenges, wheel, feed, admin, events, assignments, telegram, content
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
@@ -15,3 +15,4 @@ router.include_router(admin.router)
|
|||||||
router.include_router(events.router)
|
router.include_router(events.router)
|
||||||
router.include_router(assignments.router)
|
router.include_router(assignments.router)
|
||||||
router.include_router(telegram.router)
|
router.include_router(telegram.router)
|
||||||
|
router.include_router(content.router)
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
from fastapi import APIRouter, HTTPException, Query
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser, require_admin
|
from app.api.deps import DbSession, CurrentUser, require_admin_with_2fa
|
||||||
from app.models import User, UserRole, Marathon, Participant, Game
|
from app.models import User, UserRole, Marathon, MarathonStatus, Participant, Game, AdminLog, AdminActionType, StaticContent
|
||||||
from app.schemas import UserPublic, MarathonListItem, MessageResponse
|
from app.schemas import (
|
||||||
|
UserPublic, MessageResponse,
|
||||||
|
AdminUserResponse, BanUserRequest, AdminResetPasswordRequest, AdminLogResponse, AdminLogsListResponse,
|
||||||
|
BroadcastRequest, BroadcastResponse, StaticContentResponse, StaticContentUpdate,
|
||||||
|
StaticContentCreate, DashboardStats
|
||||||
|
)
|
||||||
|
from app.core.security import get_password_hash
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
from app.core.rate_limit import limiter
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
@@ -14,21 +23,6 @@ class SetUserRole(BaseModel):
|
|||||||
role: str = Field(..., pattern="^(user|admin)$")
|
role: str = Field(..., pattern="^(user|admin)$")
|
||||||
|
|
||||||
|
|
||||||
class AdminUserResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
login: str
|
|
||||||
nickname: str
|
|
||||||
role: str
|
|
||||||
avatar_url: str | None = None
|
|
||||||
telegram_id: int | None = None
|
|
||||||
telegram_username: str | None = None
|
|
||||||
marathons_count: int = 0
|
|
||||||
created_at: str
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
class AdminMarathonResponse(BaseModel):
|
class AdminMarathonResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
@@ -44,6 +38,29 @@ class AdminMarathonResponse(BaseModel):
|
|||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Helper Functions ============
|
||||||
|
async def log_admin_action(
|
||||||
|
db,
|
||||||
|
admin_id: int,
|
||||||
|
action: str,
|
||||||
|
target_type: str,
|
||||||
|
target_id: int,
|
||||||
|
details: dict | None = None,
|
||||||
|
ip_address: str | None = None
|
||||||
|
):
|
||||||
|
"""Log an admin action."""
|
||||||
|
log = AdminLog(
|
||||||
|
admin_id=admin_id,
|
||||||
|
action=action,
|
||||||
|
target_type=target_type,
|
||||||
|
target_id=target_id,
|
||||||
|
details=details,
|
||||||
|
ip_address=ip_address,
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users", response_model=list[AdminUserResponse])
|
@router.get("/users", response_model=list[AdminUserResponse])
|
||||||
async def list_users(
|
async def list_users(
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
@@ -51,9 +68,10 @@ async def list_users(
|
|||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(50, ge=1, le=100),
|
limit: int = Query(50, ge=1, le=100),
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
|
banned_only: bool = False,
|
||||||
):
|
):
|
||||||
"""List all users. Admin only."""
|
"""List all users. Admin only."""
|
||||||
require_admin(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
query = select(User).order_by(User.created_at.desc())
|
query = select(User).order_by(User.created_at.desc())
|
||||||
|
|
||||||
@@ -63,6 +81,9 @@ async def list_users(
|
|||||||
(User.nickname.ilike(f"%{search}%"))
|
(User.nickname.ilike(f"%{search}%"))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if banned_only:
|
||||||
|
query = query.where(User.is_banned == True)
|
||||||
|
|
||||||
query = query.offset(skip).limit(limit)
|
query = query.offset(skip).limit(limit)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
users = result.scalars().all()
|
users = result.scalars().all()
|
||||||
@@ -83,6 +104,10 @@ async def list_users(
|
|||||||
telegram_username=user.telegram_username,
|
telegram_username=user.telegram_username,
|
||||||
marathons_count=marathons_count,
|
marathons_count=marathons_count,
|
||||||
created_at=user.created_at.isoformat(),
|
created_at=user.created_at.isoformat(),
|
||||||
|
is_banned=user.is_banned,
|
||||||
|
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
||||||
|
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
||||||
|
ban_reason=user.ban_reason,
|
||||||
))
|
))
|
||||||
|
|
||||||
return response
|
return response
|
||||||
@@ -91,7 +116,7 @@ async def list_users(
|
|||||||
@router.get("/users/{user_id}", response_model=AdminUserResponse)
|
@router.get("/users/{user_id}", response_model=AdminUserResponse)
|
||||||
async def get_user(user_id: int, current_user: CurrentUser, db: DbSession):
|
async def get_user(user_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
"""Get user details. Admin only."""
|
"""Get user details. Admin only."""
|
||||||
require_admin(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
result = await db.execute(select(User).where(User.id == user_id))
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
@@ -112,6 +137,10 @@ async def get_user(user_id: int, current_user: CurrentUser, db: DbSession):
|
|||||||
telegram_username=user.telegram_username,
|
telegram_username=user.telegram_username,
|
||||||
marathons_count=marathons_count,
|
marathons_count=marathons_count,
|
||||||
created_at=user.created_at.isoformat(),
|
created_at=user.created_at.isoformat(),
|
||||||
|
is_banned=user.is_banned,
|
||||||
|
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
||||||
|
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
||||||
|
ban_reason=user.ban_reason,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -121,9 +150,10 @@ async def set_user_role(
|
|||||||
data: SetUserRole,
|
data: SetUserRole,
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
|
request: Request,
|
||||||
):
|
):
|
||||||
"""Set user's global role. Admin only."""
|
"""Set user's global role. Admin only."""
|
||||||
require_admin(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
# Cannot change own role
|
# Cannot change own role
|
||||||
if user_id == current_user.id:
|
if user_id == current_user.id:
|
||||||
@@ -134,10 +164,19 @@ async def set_user_role(
|
|||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
old_role = user.role
|
||||||
user.role = data.role
|
user.role = data.role
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(user)
|
await db.refresh(user)
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.USER_ROLE_CHANGE.value,
|
||||||
|
"user", user_id,
|
||||||
|
{"old_role": old_role, "new_role": data.role, "nickname": user.nickname},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
marathons_count = await db.scalar(
|
marathons_count = await db.scalar(
|
||||||
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
)
|
)
|
||||||
@@ -152,13 +191,17 @@ async def set_user_role(
|
|||||||
telegram_username=user.telegram_username,
|
telegram_username=user.telegram_username,
|
||||||
marathons_count=marathons_count,
|
marathons_count=marathons_count,
|
||||||
created_at=user.created_at.isoformat(),
|
created_at=user.created_at.isoformat(),
|
||||||
|
is_banned=user.is_banned,
|
||||||
|
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
||||||
|
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
||||||
|
ban_reason=user.ban_reason,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/users/{user_id}", response_model=MessageResponse)
|
@router.delete("/users/{user_id}", response_model=MessageResponse)
|
||||||
async def delete_user(user_id: int, current_user: CurrentUser, db: DbSession):
|
async def delete_user(user_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
"""Delete a user. Admin only."""
|
"""Delete a user. Admin only."""
|
||||||
require_admin(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
# Cannot delete yourself
|
# Cannot delete yourself
|
||||||
if user_id == current_user.id:
|
if user_id == current_user.id:
|
||||||
@@ -188,7 +231,7 @@ async def list_marathons(
|
|||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
):
|
):
|
||||||
"""List all marathons. Admin only."""
|
"""List all marathons. Admin only."""
|
||||||
require_admin(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(Marathon)
|
select(Marathon)
|
||||||
@@ -227,25 +270,34 @@ async def list_marathons(
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/marathons/{marathon_id}", response_model=MessageResponse)
|
@router.delete("/marathons/{marathon_id}", response_model=MessageResponse)
|
||||||
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def delete_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession, request: Request):
|
||||||
"""Delete a marathon. Admin only."""
|
"""Delete a marathon. Admin only."""
|
||||||
require_admin(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
marathon = result.scalar_one_or_none()
|
marathon = result.scalar_one_or_none()
|
||||||
if not marathon:
|
if not marathon:
|
||||||
raise HTTPException(status_code=404, detail="Marathon not found")
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
marathon_title = marathon.title
|
||||||
await db.delete(marathon)
|
await db.delete(marathon)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.MARATHON_DELETE.value,
|
||||||
|
"marathon", marathon_id,
|
||||||
|
{"title": marathon_title},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
return MessageResponse(message="Marathon deleted")
|
return MessageResponse(message="Marathon deleted")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
async def get_stats(current_user: CurrentUser, db: DbSession):
|
async def get_stats(current_user: CurrentUser, db: DbSession):
|
||||||
"""Get platform statistics. Admin only."""
|
"""Get platform statistics. Admin only."""
|
||||||
require_admin(current_user)
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
users_count = await db.scalar(select(func.count()).select_from(User))
|
users_count = await db.scalar(select(func.count()).select_from(User))
|
||||||
marathons_count = await db.scalar(select(func.count()).select_from(Marathon))
|
marathons_count = await db.scalar(select(func.count()).select_from(Marathon))
|
||||||
@@ -258,3 +310,530 @@ async def get_stats(current_user: CurrentUser, db: DbSession):
|
|||||||
"games_count": games_count,
|
"games_count": games_count,
|
||||||
"total_participations": participants_count,
|
"total_participations": participants_count,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Ban/Unban Users ============
|
||||||
|
@router.post("/users/{user_id}/ban", response_model=AdminUserResponse)
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def ban_user(
|
||||||
|
request: Request,
|
||||||
|
user_id: int,
|
||||||
|
data: BanUserRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Ban a user. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
if user_id == current_user.id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot ban yourself")
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
if user.role == UserRole.ADMIN.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot ban another admin")
|
||||||
|
|
||||||
|
if user.is_banned:
|
||||||
|
raise HTTPException(status_code=400, detail="User is already banned")
|
||||||
|
|
||||||
|
user.is_banned = True
|
||||||
|
user.banned_at = datetime.utcnow()
|
||||||
|
# Normalize to naive datetime (remove tzinfo) to match banned_at
|
||||||
|
user.banned_until = data.banned_until.replace(tzinfo=None) if data.banned_until else None
|
||||||
|
user.banned_by_id = current_user.id
|
||||||
|
user.ban_reason = data.reason
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.USER_BAN.value,
|
||||||
|
"user", user_id,
|
||||||
|
{"nickname": user.nickname, "reason": data.reason},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
marathons_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AdminUserResponse(
|
||||||
|
id=user.id,
|
||||||
|
login=user.login,
|
||||||
|
nickname=user.nickname,
|
||||||
|
role=user.role,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
telegram_id=user.telegram_id,
|
||||||
|
telegram_username=user.telegram_username,
|
||||||
|
marathons_count=marathons_count,
|
||||||
|
created_at=user.created_at.isoformat(),
|
||||||
|
is_banned=user.is_banned,
|
||||||
|
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
||||||
|
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
||||||
|
ban_reason=user.ban_reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/unban", response_model=AdminUserResponse)
|
||||||
|
async def unban_user(
|
||||||
|
request: Request,
|
||||||
|
user_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Unban a user. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
if not user.is_banned:
|
||||||
|
raise HTTPException(status_code=400, detail="User is not banned")
|
||||||
|
|
||||||
|
user.is_banned = False
|
||||||
|
user.banned_at = None
|
||||||
|
user.banned_until = None
|
||||||
|
user.banned_by_id = None
|
||||||
|
user.ban_reason = None
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.USER_UNBAN.value,
|
||||||
|
"user", user_id,
|
||||||
|
{"nickname": user.nickname},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
marathons_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AdminUserResponse(
|
||||||
|
id=user.id,
|
||||||
|
login=user.login,
|
||||||
|
nickname=user.nickname,
|
||||||
|
role=user.role,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
telegram_id=user.telegram_id,
|
||||||
|
telegram_username=user.telegram_username,
|
||||||
|
marathons_count=marathons_count,
|
||||||
|
created_at=user.created_at.isoformat(),
|
||||||
|
is_banned=user.is_banned,
|
||||||
|
banned_at=None,
|
||||||
|
banned_until=None,
|
||||||
|
ban_reason=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Reset Password ============
|
||||||
|
@router.post("/users/{user_id}/reset-password", response_model=AdminUserResponse)
|
||||||
|
async def reset_user_password(
|
||||||
|
request: Request,
|
||||||
|
user_id: int,
|
||||||
|
data: AdminResetPasswordRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Reset user password. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Hash and save new password
|
||||||
|
user.password_hash = get_password_hash(data.new_password)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.USER_PASSWORD_RESET.value,
|
||||||
|
"user", user_id,
|
||||||
|
{"nickname": user.nickname},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify user via Telegram if linked
|
||||||
|
if user.telegram_id:
|
||||||
|
await telegram_notifier.send_message(
|
||||||
|
user.telegram_id,
|
||||||
|
"🔐 <b>Ваш пароль был сброшен</b>\n\n"
|
||||||
|
"Администратор установил вам новый пароль. "
|
||||||
|
"Если это были не вы, свяжитесь с поддержкой."
|
||||||
|
)
|
||||||
|
|
||||||
|
marathons_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AdminUserResponse(
|
||||||
|
id=user.id,
|
||||||
|
login=user.login,
|
||||||
|
nickname=user.nickname,
|
||||||
|
role=user.role,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
telegram_id=user.telegram_id,
|
||||||
|
telegram_username=user.telegram_username,
|
||||||
|
marathons_count=marathons_count,
|
||||||
|
created_at=user.created_at.isoformat(),
|
||||||
|
is_banned=user.is_banned,
|
||||||
|
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
||||||
|
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
||||||
|
ban_reason=user.ban_reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Force Finish Marathon ============
|
||||||
|
@router.post("/marathons/{marathon_id}/force-finish", response_model=MessageResponse)
|
||||||
|
async def force_finish_marathon(
|
||||||
|
request: Request,
|
||||||
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Force finish a marathon. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if not marathon:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
if marathon.status == MarathonStatus.FINISHED.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Marathon is already finished")
|
||||||
|
|
||||||
|
old_status = marathon.status
|
||||||
|
marathon.status = MarathonStatus.FINISHED.value
|
||||||
|
marathon.end_date = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.MARATHON_FORCE_FINISH.value,
|
||||||
|
"marathon", marathon_id,
|
||||||
|
{"title": marathon.title, "old_status": old_status},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Notify participants
|
||||||
|
await telegram_notifier.notify_marathon_finish(db, marathon_id, marathon.title)
|
||||||
|
|
||||||
|
return MessageResponse(message="Marathon finished")
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Admin Logs ============
|
||||||
|
@router.get("/logs", response_model=AdminLogsListResponse)
|
||||||
|
async def get_logs(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=100),
|
||||||
|
action: str | None = None,
|
||||||
|
admin_id: int | None = None,
|
||||||
|
):
|
||||||
|
"""Get admin action logs. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
query = (
|
||||||
|
select(AdminLog)
|
||||||
|
.options(selectinload(AdminLog.admin))
|
||||||
|
.order_by(AdminLog.created_at.desc())
|
||||||
|
)
|
||||||
|
|
||||||
|
if action:
|
||||||
|
query = query.where(AdminLog.action == action)
|
||||||
|
if admin_id:
|
||||||
|
query = query.where(AdminLog.admin_id == admin_id)
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
count_query = select(func.count()).select_from(AdminLog)
|
||||||
|
if action:
|
||||||
|
count_query = count_query.where(AdminLog.action == action)
|
||||||
|
if admin_id:
|
||||||
|
count_query = count_query.where(AdminLog.admin_id == admin_id)
|
||||||
|
total = await db.scalar(count_query)
|
||||||
|
|
||||||
|
query = query.offset(skip).limit(limit)
|
||||||
|
result = await db.execute(query)
|
||||||
|
logs = result.scalars().all()
|
||||||
|
|
||||||
|
return AdminLogsListResponse(
|
||||||
|
logs=[
|
||||||
|
AdminLogResponse(
|
||||||
|
id=log.id,
|
||||||
|
admin_id=log.admin_id,
|
||||||
|
admin_nickname=log.admin.nickname if log.admin else None,
|
||||||
|
action=log.action,
|
||||||
|
target_type=log.target_type,
|
||||||
|
target_id=log.target_id,
|
||||||
|
details=log.details,
|
||||||
|
ip_address=log.ip_address,
|
||||||
|
created_at=log.created_at,
|
||||||
|
)
|
||||||
|
for log in logs
|
||||||
|
],
|
||||||
|
total=total or 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Broadcast ============
|
||||||
|
@router.post("/broadcast/all", response_model=BroadcastResponse)
|
||||||
|
@limiter.limit("1/minute")
|
||||||
|
async def broadcast_to_all(
|
||||||
|
request: Request,
|
||||||
|
data: BroadcastRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Send broadcast message to all users with Telegram linked. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
# Get all users with telegram_id
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id.isnot(None))
|
||||||
|
)
|
||||||
|
users = result.scalars().all()
|
||||||
|
|
||||||
|
total_count = len(users)
|
||||||
|
sent_count = 0
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
if await telegram_notifier.send_message(user.telegram_id, data.message):
|
||||||
|
sent_count += 1
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.BROADCAST_ALL.value,
|
||||||
|
"broadcast", 0,
|
||||||
|
{"message": data.message[:100], "sent": sent_count, "total": total_count},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return BroadcastResponse(sent_count=sent_count, total_count=total_count)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/broadcast/marathon/{marathon_id}", response_model=BroadcastResponse)
|
||||||
|
@limiter.limit("3/minute")
|
||||||
|
async def broadcast_to_marathon(
|
||||||
|
request: Request,
|
||||||
|
marathon_id: int,
|
||||||
|
data: BroadcastRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Send broadcast message to marathon participants. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
# Check marathon exists
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if not marathon:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
# Get participants count
|
||||||
|
total_result = await db.execute(
|
||||||
|
select(User)
|
||||||
|
.join(Participant, Participant.user_id == User.id)
|
||||||
|
.where(
|
||||||
|
Participant.marathon_id == marathon_id,
|
||||||
|
User.telegram_id.isnot(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
users = total_result.scalars().all()
|
||||||
|
total_count = len(users)
|
||||||
|
|
||||||
|
sent_count = await telegram_notifier.notify_marathon_participants(
|
||||||
|
db, marathon_id, data.message
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.BROADCAST_MARATHON.value,
|
||||||
|
"marathon", marathon_id,
|
||||||
|
{"title": marathon.title, "message": data.message[:100], "sent": sent_count, "total": total_count},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return BroadcastResponse(sent_count=sent_count, total_count=total_count)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Static Content ============
|
||||||
|
@router.get("/content", response_model=list[StaticContentResponse])
|
||||||
|
async def list_content(current_user: CurrentUser, db: DbSession):
|
||||||
|
"""List all static content. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(StaticContent).order_by(StaticContent.key)
|
||||||
|
)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/content/{key}", response_model=StaticContentResponse)
|
||||||
|
async def get_content(key: str, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Get static content by key. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(StaticContent).where(StaticContent.key == key)
|
||||||
|
)
|
||||||
|
content = result.scalar_one_or_none()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=404, detail="Content not found")
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/content/{key}", response_model=StaticContentResponse)
|
||||||
|
async def update_content(
|
||||||
|
request: Request,
|
||||||
|
key: str,
|
||||||
|
data: StaticContentUpdate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Update static content. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(StaticContent).where(StaticContent.key == key)
|
||||||
|
)
|
||||||
|
content = result.scalar_one_or_none()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=404, detail="Content not found")
|
||||||
|
|
||||||
|
content.title = data.title
|
||||||
|
content.content = data.content
|
||||||
|
content.updated_by_id = current_user.id
|
||||||
|
content.updated_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(content)
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.CONTENT_UPDATE.value,
|
||||||
|
"content", content.id,
|
||||||
|
{"key": key, "title": data.title},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/content", response_model=StaticContentResponse)
|
||||||
|
async def create_content(
|
||||||
|
request: Request,
|
||||||
|
data: StaticContentCreate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Create static content. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
# Check if key exists
|
||||||
|
result = await db.execute(
|
||||||
|
select(StaticContent).where(StaticContent.key == data.key)
|
||||||
|
)
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="Content with this key already exists")
|
||||||
|
|
||||||
|
content = StaticContent(
|
||||||
|
key=data.key,
|
||||||
|
title=data.title,
|
||||||
|
content=data.content,
|
||||||
|
updated_by_id=current_user.id,
|
||||||
|
)
|
||||||
|
db.add(content)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(content)
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/content/{key}", response_model=MessageResponse)
|
||||||
|
async def delete_content(
|
||||||
|
key: str,
|
||||||
|
request: Request,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Delete static content. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(StaticContent).where(StaticContent.key == key)
|
||||||
|
)
|
||||||
|
content = result.scalar_one_or_none()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=404, detail="Content not found")
|
||||||
|
|
||||||
|
await db.delete(content)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Log action
|
||||||
|
await log_admin_action(
|
||||||
|
db, current_user.id, AdminActionType.CONTENT_UPDATE.value,
|
||||||
|
"static_content", content.id,
|
||||||
|
{"action": "delete", "key": key},
|
||||||
|
request.client.host if request.client else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": f"Content '{key}' deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Dashboard ============
|
||||||
|
@router.get("/dashboard", response_model=DashboardStats)
|
||||||
|
async def get_dashboard(current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Get dashboard statistics. Admin only."""
|
||||||
|
require_admin_with_2fa(current_user)
|
||||||
|
|
||||||
|
users_count = await db.scalar(select(func.count()).select_from(User))
|
||||||
|
banned_users_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(User).where(User.is_banned == True)
|
||||||
|
)
|
||||||
|
marathons_count = await db.scalar(select(func.count()).select_from(Marathon))
|
||||||
|
active_marathons_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Marathon).where(Marathon.status == MarathonStatus.ACTIVE.value)
|
||||||
|
)
|
||||||
|
games_count = await db.scalar(select(func.count()).select_from(Game))
|
||||||
|
total_participations = await db.scalar(select(func.count()).select_from(Participant))
|
||||||
|
|
||||||
|
# Get recent logs
|
||||||
|
result = await db.execute(
|
||||||
|
select(AdminLog)
|
||||||
|
.options(selectinload(AdminLog.admin))
|
||||||
|
.order_by(AdminLog.created_at.desc())
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
recent_logs = result.scalars().all()
|
||||||
|
|
||||||
|
return DashboardStats(
|
||||||
|
users_count=users_count or 0,
|
||||||
|
banned_users_count=banned_users_count or 0,
|
||||||
|
marathons_count=marathons_count or 0,
|
||||||
|
active_marathons_count=active_marathons_count or 0,
|
||||||
|
games_count=games_count or 0,
|
||||||
|
total_participations=total_participations or 0,
|
||||||
|
recent_logs=[
|
||||||
|
AdminLogResponse(
|
||||||
|
id=log.id,
|
||||||
|
admin_id=log.admin_id,
|
||||||
|
admin_nickname=log.admin.nickname if log.admin else None,
|
||||||
|
action=log.action,
|
||||||
|
target_type=log.target_type,
|
||||||
|
target_id=log.target_id,
|
||||||
|
details=log.details,
|
||||||
|
ip_address=log.ip_address,
|
||||||
|
created_at=log.created_at,
|
||||||
|
)
|
||||||
|
for log in recent_logs
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
from fastapi import APIRouter, HTTPException, status
|
from datetime import datetime, timedelta
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
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.models import User
|
from app.core.rate_limit import limiter
|
||||||
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPublic
|
from app.models import User, UserRole, Admin2FASession
|
||||||
|
from app.schemas import UserRegister, UserLogin, TokenResponse, UserPrivate, LoginResponse
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", response_model=TokenResponse)
|
@router.post("/register", response_model=TokenResponse)
|
||||||
async def register(data: UserRegister, db: DbSession):
|
@limiter.limit("5/minute")
|
||||||
|
async def register(request: Request, data: UserRegister, db: DbSession):
|
||||||
# Check if login already exists
|
# Check if login already exists
|
||||||
result = await db.execute(select(User).where(User.login == data.login.lower()))
|
result = await db.execute(select(User).where(User.login == data.login.lower()))
|
||||||
if result.scalar_one_or_none():
|
if result.scalar_one_or_none():
|
||||||
@@ -34,12 +40,13 @@ async def register(data: UserRegister, db: DbSession):
|
|||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
user=UserPublic.model_validate(user),
|
user=UserPrivate.model_validate(user),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=LoginResponse)
|
||||||
async def login(data: UserLogin, db: DbSession):
|
@limiter.limit("10/minute")
|
||||||
|
async def login(request: Request, data: UserLogin, db: DbSession):
|
||||||
# Find user
|
# Find user
|
||||||
result = await db.execute(select(User).where(User.login == data.login.lower()))
|
result = await db.execute(select(User).where(User.login == data.login.lower()))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
@@ -50,15 +57,109 @@ async def login(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)
|
||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
user=UserPublic.model_validate(user),
|
user=UserPrivate.model_validate(user),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserPublic)
|
@router.get("/me", response_model=UserPrivate)
|
||||||
async def get_me(current_user: CurrentUser):
|
async def get_me(current_user: CurrentUser):
|
||||||
return UserPublic.model_validate(current_user)
|
"""Get current user's full profile (including private data)"""
|
||||||
|
return UserPrivate.model_validate(current_user)
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -13,8 +14,11 @@ from app.schemas import (
|
|||||||
ChallengePreview,
|
ChallengePreview,
|
||||||
ChallengesPreviewResponse,
|
ChallengesPreviewResponse,
|
||||||
ChallengesSaveRequest,
|
ChallengesSaveRequest,
|
||||||
|
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"])
|
||||||
|
|
||||||
@@ -22,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()
|
||||||
@@ -31,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)
|
||||||
@@ -53,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])
|
||||||
@@ -93,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)
|
||||||
@@ -165,29 +168,22 @@ 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)
|
||||||
async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def preview_challenges(
|
||||||
|
marathon_id: int,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
data: ChallengesGenerateRequest | None = None,
|
||||||
|
):
|
||||||
"""Generate challenges preview for approved games in marathon using GPT (without saving). Organizers only."""
|
"""Generate challenges preview for approved games in marathon using GPT (without saving). Organizers only."""
|
||||||
# Check marathon
|
# Check marathon
|
||||||
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
@@ -202,21 +198,35 @@ async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: Db
|
|||||||
await require_organizer(db, current_user, marathon_id)
|
await require_organizer(db, current_user, marathon_id)
|
||||||
|
|
||||||
# Get only APPROVED games
|
# Get only APPROVED games
|
||||||
result = await db.execute(
|
query = select(Game).where(
|
||||||
select(Game).where(
|
|
||||||
Game.marathon_id == marathon_id,
|
Game.marathon_id == marathon_id,
|
||||||
Game.status == GameStatus.APPROVED.value,
|
Game.status == GameStatus.APPROVED.value,
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
# Filter by specific game IDs if provided
|
||||||
|
if data and data.game_ids:
|
||||||
|
query = query.where(Game.id.in_(data.game_ids))
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
games = result.scalars().all()
|
games = result.scalars().all()
|
||||||
|
|
||||||
if not games:
|
if not games:
|
||||||
raise HTTPException(status_code=400, detail="No approved games in marathon")
|
raise HTTPException(status_code=400, detail="No approved games found")
|
||||||
|
|
||||||
# Filter games that don't have challenges yet
|
# Build games list for generation (skip games that already have challenges, unless specific IDs requested)
|
||||||
games_to_generate = []
|
games_to_generate = []
|
||||||
game_map = {}
|
game_map = {}
|
||||||
for game in games:
|
for game in games:
|
||||||
|
# If specific games requested, generate even if they have challenges
|
||||||
|
if data and data.game_ids:
|
||||||
|
games_to_generate.append({
|
||||||
|
"id": game.id,
|
||||||
|
"title": game.title,
|
||||||
|
"genre": game.genre
|
||||||
|
})
|
||||||
|
game_map[game.id] = game.title
|
||||||
|
else:
|
||||||
|
# Otherwise only generate for games without challenges
|
||||||
existing = await db.scalar(
|
existing = await db.scalar(
|
||||||
select(Challenge.id).where(Challenge.game_id == game.id).limit(1)
|
select(Challenge.id).where(Challenge.game_id == game.id).limit(1)
|
||||||
)
|
)
|
||||||
@@ -366,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
|
||||||
@@ -394,10 +390,206 @@ async def delete_challenge(challenge_id: int, current_user: CurrentUser, db: DbS
|
|||||||
if marathon.status != MarathonStatus.PREPARING.value:
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
raise HTTPException(status_code=400, detail="Cannot delete challenges from active or finished marathon")
|
raise HTTPException(status_code=400, detail="Cannot delete challenges from active or finished marathon")
|
||||||
|
|
||||||
# Only organizers can delete challenges
|
participant = await get_participant(db, current_user.id, challenge.game.marathon_id)
|
||||||
await require_organizer(db, current_user, challenge.game.marathon_id)
|
|
||||||
|
# Check permissions
|
||||||
|
if current_user.is_admin or (participant and participant.is_organizer):
|
||||||
|
# Organizers can delete any challenge
|
||||||
|
pass
|
||||||
|
elif challenge.proposed_by_id == current_user.id and challenge.status == ChallengeStatus.PENDING.value:
|
||||||
|
# Participants can delete their own pending challenges
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=403, detail="You can only delete your own pending challenges")
|
||||||
|
|
||||||
await db.delete(challenge)
|
await db.delete(challenge)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return MessageResponse(message="Challenge deleted")
|
return MessageResponse(message="Challenge deleted")
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Proposed challenges endpoints ============
|
||||||
|
|
||||||
|
@router.post("/games/{game_id}/propose-challenge", response_model=ChallengeResponse)
|
||||||
|
async def propose_challenge(
|
||||||
|
game_id: int,
|
||||||
|
data: ChallengePropose,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Propose a challenge for a game. Participants only, during PREPARING phase."""
|
||||||
|
# Get game
|
||||||
|
result = await db.execute(select(Game).where(Game.id == game_id))
|
||||||
|
game = result.scalar_one_or_none()
|
||||||
|
if not game:
|
||||||
|
raise HTTPException(status_code=404, detail="Game not found")
|
||||||
|
|
||||||
|
# Check marathon is in preparing state
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
|
||||||
|
marathon = result.scalar_one()
|
||||||
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot propose challenges to active or finished marathon")
|
||||||
|
|
||||||
|
# Check user is participant
|
||||||
|
participant = await get_participant(db, current_user.id, game.marathon_id)
|
||||||
|
if not participant and not current_user.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
|
# Can only propose challenges to approved games
|
||||||
|
if game.status != GameStatus.APPROVED.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Can only propose challenges to approved games")
|
||||||
|
|
||||||
|
challenge = Challenge(
|
||||||
|
game_id=game_id,
|
||||||
|
title=data.title,
|
||||||
|
description=data.description,
|
||||||
|
type=data.type.value,
|
||||||
|
difficulty=data.difficulty.value,
|
||||||
|
points=data.points,
|
||||||
|
estimated_time=data.estimated_time,
|
||||||
|
proof_type=data.proof_type.value,
|
||||||
|
proof_hint=data.proof_hint,
|
||||||
|
is_generated=False,
|
||||||
|
proposed_by_id=current_user.id,
|
||||||
|
status=ChallengeStatus.PENDING.value,
|
||||||
|
)
|
||||||
|
db.add(challenge)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(challenge)
|
||||||
|
|
||||||
|
# Load proposed_by relationship
|
||||||
|
challenge.proposed_by = current_user
|
||||||
|
|
||||||
|
return build_challenge_response(challenge, game)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathons/{marathon_id}/proposed-challenges", response_model=list[ChallengeResponse])
|
||||||
|
async def list_proposed_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""List all pending proposed challenges for a marathon. Organizers only."""
|
||||||
|
# Check marathon exists
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if not marathon:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
# Only organizers can see all proposed challenges
|
||||||
|
await require_organizer(db, current_user, marathon_id)
|
||||||
|
|
||||||
|
# Get all pending challenges from approved games
|
||||||
|
result = await db.execute(
|
||||||
|
select(Challenge)
|
||||||
|
.join(Game, Challenge.game_id == Game.id)
|
||||||
|
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||||
|
.where(
|
||||||
|
Game.marathon_id == marathon_id,
|
||||||
|
Game.status == GameStatus.APPROVED.value,
|
||||||
|
Challenge.status == ChallengeStatus.PENDING.value,
|
||||||
|
)
|
||||||
|
.order_by(Challenge.created_at.desc())
|
||||||
|
)
|
||||||
|
challenges = result.scalars().all()
|
||||||
|
|
||||||
|
return [build_challenge_response(c, c.game) for c in challenges]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/marathons/{marathon_id}/my-proposed-challenges", response_model=list[ChallengeResponse])
|
||||||
|
async def list_my_proposed_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""List current user's proposed challenges for a marathon."""
|
||||||
|
# Check marathon exists
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
|
||||||
|
marathon = result.scalar_one_or_none()
|
||||||
|
if not marathon:
|
||||||
|
raise HTTPException(status_code=404, detail="Marathon not found")
|
||||||
|
|
||||||
|
# Check user is participant
|
||||||
|
participant = await get_participant(db, current_user.id, marathon_id)
|
||||||
|
if not participant and not current_user.is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="You are not a participant of this marathon")
|
||||||
|
|
||||||
|
# Get user's proposed challenges
|
||||||
|
result = await db.execute(
|
||||||
|
select(Challenge)
|
||||||
|
.join(Game, Challenge.game_id == Game.id)
|
||||||
|
.options(selectinload(Challenge.game), selectinload(Challenge.proposed_by))
|
||||||
|
.where(
|
||||||
|
Game.marathon_id == marathon_id,
|
||||||
|
Challenge.proposed_by_id == current_user.id,
|
||||||
|
)
|
||||||
|
.order_by(Challenge.created_at.desc())
|
||||||
|
)
|
||||||
|
challenges = result.scalars().all()
|
||||||
|
|
||||||
|
return [build_challenge_response(c, c.game) for c in challenges]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/challenges/{challenge_id}/approve", response_model=ChallengeResponse)
|
||||||
|
async def approve_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Approve a proposed challenge. Organizers only."""
|
||||||
|
challenge = await get_challenge_or_404(db, challenge_id)
|
||||||
|
|
||||||
|
# Check marathon is in preparing state
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id))
|
||||||
|
marathon = result.scalar_one()
|
||||||
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot approve challenges in active or finished marathon")
|
||||||
|
|
||||||
|
# Only organizers can approve
|
||||||
|
await require_organizer(db, current_user, challenge.game.marathon_id)
|
||||||
|
|
||||||
|
if challenge.status != ChallengeStatus.PENDING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Challenge is not pending")
|
||||||
|
|
||||||
|
challenge.status = ChallengeStatus.APPROVED.value
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(challenge)
|
||||||
|
|
||||||
|
# Send Telegram notification to proposer
|
||||||
|
if challenge.proposed_by_id:
|
||||||
|
await telegram_notifier.notify_challenge_approved(
|
||||||
|
db,
|
||||||
|
challenge.proposed_by_id,
|
||||||
|
marathon.title,
|
||||||
|
challenge.game.title,
|
||||||
|
challenge.title
|
||||||
|
)
|
||||||
|
|
||||||
|
return build_challenge_response(challenge, challenge.game)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/challenges/{challenge_id}/reject", response_model=ChallengeResponse)
|
||||||
|
async def reject_challenge(challenge_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Reject a proposed challenge. Organizers only."""
|
||||||
|
challenge = await get_challenge_or_404(db, challenge_id)
|
||||||
|
|
||||||
|
# Check marathon is in preparing state
|
||||||
|
result = await db.execute(select(Marathon).where(Marathon.id == challenge.game.marathon_id))
|
||||||
|
marathon = result.scalar_one()
|
||||||
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot reject challenges in active or finished marathon")
|
||||||
|
|
||||||
|
# Only organizers can reject
|
||||||
|
await require_organizer(db, current_user, challenge.game.marathon_id)
|
||||||
|
|
||||||
|
if challenge.status != ChallengeStatus.PENDING.value:
|
||||||
|
raise HTTPException(status_code=400, detail="Challenge is not pending")
|
||||||
|
|
||||||
|
# Save info for notification before changing status
|
||||||
|
proposer_id = challenge.proposed_by_id
|
||||||
|
game_title = challenge.game.title
|
||||||
|
challenge_title = challenge.title
|
||||||
|
|
||||||
|
challenge.status = ChallengeStatus.REJECTED.value
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(challenge)
|
||||||
|
|
||||||
|
# Send Telegram notification to proposer
|
||||||
|
if proposer_id:
|
||||||
|
await telegram_notifier.notify_challenge_rejected(
|
||||||
|
db,
|
||||||
|
proposer_id,
|
||||||
|
marathon.title,
|
||||||
|
game_title,
|
||||||
|
challenge_title
|
||||||
|
)
|
||||||
|
|
||||||
|
return build_challenge_response(challenge, challenge.game)
|
||||||
|
|||||||
20
backend/app/api/v1/content.py
Normal file
20
backend/app/api/v1/content.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.deps import DbSession
|
||||||
|
from app.models import StaticContent
|
||||||
|
from app.schemas import StaticContentResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/content", tags=["content"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{key}", response_model=StaticContentResponse)
|
||||||
|
async def get_public_content(key: str, db: DbSession):
|
||||||
|
"""Get public static content by key. No authentication required."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(StaticContent).where(StaticContent.key == key)
|
||||||
|
)
|
||||||
|
content = result.scalar_one_or_none()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(status_code=404, detail="Content not found")
|
||||||
|
return content
|
||||||
@@ -10,6 +10,7 @@ from app.core.config import settings
|
|||||||
from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType
|
from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType
|
||||||
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
|
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
|
||||||
from app.services.storage import storage_service
|
from app.services.storage import storage_service
|
||||||
|
from app.services.telegram_notifier import telegram_notifier
|
||||||
|
|
||||||
router = APIRouter(tags=["games"])
|
router = APIRouter(tags=["games"])
|
||||||
|
|
||||||
@@ -268,6 +269,13 @@ async def approve_game(game_id: int, current_user: CurrentUser, db: DbSession):
|
|||||||
if game.status != GameStatus.PENDING.value:
|
if game.status != GameStatus.PENDING.value:
|
||||||
raise HTTPException(status_code=400, detail="Game is not pending")
|
raise HTTPException(status_code=400, detail="Game is not pending")
|
||||||
|
|
||||||
|
# Get marathon title for notification
|
||||||
|
marathon_result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
|
||||||
|
marathon = marathon_result.scalar_one()
|
||||||
|
|
||||||
|
# Save proposer id before status change
|
||||||
|
proposer_id = game.proposed_by_id
|
||||||
|
|
||||||
game.status = GameStatus.APPROVED.value
|
game.status = GameStatus.APPROVED.value
|
||||||
game.approved_by_id = current_user.id
|
game.approved_by_id = current_user.id
|
||||||
|
|
||||||
@@ -283,6 +291,12 @@ async def approve_game(game_id: int, current_user: CurrentUser, db: DbSession):
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(game)
|
await db.refresh(game)
|
||||||
|
|
||||||
|
# Notify proposer (if not self-approving)
|
||||||
|
if proposer_id and proposer_id != current_user.id:
|
||||||
|
await telegram_notifier.notify_game_approved(
|
||||||
|
db, proposer_id, marathon.title, game.title
|
||||||
|
)
|
||||||
|
|
||||||
# Need to reload relationships
|
# Need to reload relationships
|
||||||
game = await get_game_or_404(db, game_id)
|
game = await get_game_or_404(db, game_id)
|
||||||
challenges_count = await db.scalar(
|
challenges_count = await db.scalar(
|
||||||
@@ -302,6 +316,14 @@ async def reject_game(game_id: int, current_user: CurrentUser, db: DbSession):
|
|||||||
if game.status != GameStatus.PENDING.value:
|
if game.status != GameStatus.PENDING.value:
|
||||||
raise HTTPException(status_code=400, detail="Game is not pending")
|
raise HTTPException(status_code=400, detail="Game is not pending")
|
||||||
|
|
||||||
|
# Get marathon title for notification
|
||||||
|
marathon_result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
|
||||||
|
marathon = marathon_result.scalar_one()
|
||||||
|
|
||||||
|
# Save proposer id and game title before changes
|
||||||
|
proposer_id = game.proposed_by_id
|
||||||
|
game_title = game.title
|
||||||
|
|
||||||
game.status = GameStatus.REJECTED.value
|
game.status = GameStatus.REJECTED.value
|
||||||
|
|
||||||
# Log activity
|
# Log activity
|
||||||
@@ -316,6 +338,12 @@ async def reject_game(game_id: int, current_user: CurrentUser, db: DbSession):
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(game)
|
await db.refresh(game)
|
||||||
|
|
||||||
|
# Notify proposer
|
||||||
|
if proposer_id and proposer_id != current_user.id:
|
||||||
|
await telegram_notifier.notify_game_rejected(
|
||||||
|
db, proposer_id, marathon.title, game_title
|
||||||
|
)
|
||||||
|
|
||||||
# Need to reload relationships
|
# Need to reload relationships
|
||||||
game = await get_game_or_404(db, game_id)
|
game = await get_game_or_404(db, game_id)
|
||||||
challenges_count = await db.scalar(
|
challenges_count = await db.scalar(
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import secrets
|
import secrets
|
||||||
from fastapi import APIRouter, HTTPException, status
|
import string
|
||||||
|
from fastapi import APIRouter, HTTPException, status, Depends
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
@@ -9,8 +11,12 @@ from app.api.deps import (
|
|||||||
require_participant, require_organizer, require_creator,
|
require_participant, require_organizer, require_creator,
|
||||||
get_participant,
|
get_participant,
|
||||||
)
|
)
|
||||||
|
from app.core.security import decode_access_token
|
||||||
|
|
||||||
|
# Optional auth for endpoints that need it conditionally
|
||||||
|
optional_auth = HTTPBearer(auto_error=False)
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Marathon, Participant, MarathonStatus, Game, GameStatus,
|
Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
|
||||||
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
|
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
|
||||||
)
|
)
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
@@ -40,7 +46,7 @@ async def get_marathon_by_code(invite_code: str, db: DbSession):
|
|||||||
select(Marathon, func.count(Participant.id).label("participants_count"))
|
select(Marathon, func.count(Participant.id).label("participants_count"))
|
||||||
.outerjoin(Participant)
|
.outerjoin(Participant)
|
||||||
.options(selectinload(Marathon.creator))
|
.options(selectinload(Marathon.creator))
|
||||||
.where(Marathon.invite_code == invite_code)
|
.where(func.upper(Marathon.invite_code) == invite_code.upper())
|
||||||
.group_by(Marathon.id)
|
.group_by(Marathon.id)
|
||||||
)
|
)
|
||||||
row = result.first()
|
row = result.first()
|
||||||
@@ -62,7 +68,9 @@ async def get_marathon_by_code(invite_code: str, db: DbSession):
|
|||||||
|
|
||||||
|
|
||||||
def generate_invite_code() -> str:
|
def generate_invite_code() -> str:
|
||||||
return secrets.token_urlsafe(8)
|
"""Generate a clean 8-character uppercase alphanumeric code."""
|
||||||
|
alphabet = string.ascii_uppercase + string.digits
|
||||||
|
return ''.join(secrets.choice(alphabet) for _ in range(8))
|
||||||
|
|
||||||
|
|
||||||
async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
|
async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
|
||||||
@@ -185,6 +193,15 @@ async def create_marathon(
|
|||||||
async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def get_marathon(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
marathon = await get_marathon_or_404(db, marathon_id)
|
marathon = await get_marathon_or_404(db, marathon_id)
|
||||||
|
|
||||||
|
# For private marathons, require participation (or admin/creator)
|
||||||
|
if not marathon.is_public and not current_user.is_admin:
|
||||||
|
participation = await get_participation(db, current_user.id, marathon_id)
|
||||||
|
if not participation:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="You are not a participant of this private marathon",
|
||||||
|
)
|
||||||
|
|
||||||
# Count participants and approved games
|
# Count participants and approved games
|
||||||
participants_count = await db.scalar(
|
participants_count = await db.scalar(
|
||||||
select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon_id)
|
select(func.count()).select_from(Participant).where(Participant.marathon_id == marathon_id)
|
||||||
@@ -272,15 +289,33 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
|
|||||||
if marathon.status != MarathonStatus.PREPARING.value:
|
if marathon.status != MarathonStatus.PREPARING.value:
|
||||||
raise HTTPException(status_code=400, detail="Marathon is not in preparing state")
|
raise HTTPException(status_code=400, detail="Marathon is not in preparing state")
|
||||||
|
|
||||||
# Check if there are approved games with challenges
|
# Check if there are approved games
|
||||||
games_count = await db.scalar(
|
games_result = await db.execute(
|
||||||
select(func.count()).select_from(Game).where(
|
select(Game).where(
|
||||||
Game.marathon_id == marathon_id,
|
Game.marathon_id == marathon_id,
|
||||||
Game.status == GameStatus.APPROVED.value,
|
Game.status == GameStatus.APPROVED.value,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if games_count == 0:
|
approved_games = games_result.scalars().all()
|
||||||
raise HTTPException(status_code=400, detail="Add and approve at least one game before starting")
|
|
||||||
|
if len(approved_games) == 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Добавьте и одобрите хотя бы одну игру")
|
||||||
|
|
||||||
|
# Check that all approved games have at least one challenge
|
||||||
|
games_without_challenges = []
|
||||||
|
for game in approved_games:
|
||||||
|
challenge_count = await db.scalar(
|
||||||
|
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
|
||||||
|
)
|
||||||
|
if challenge_count == 0:
|
||||||
|
games_without_challenges.append(game.title)
|
||||||
|
|
||||||
|
if games_without_challenges:
|
||||||
|
games_list = ", ".join(games_without_challenges)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"У следующих игр нет челленджей: {games_list}"
|
||||||
|
)
|
||||||
|
|
||||||
marathon.status = MarathonStatus.ACTIVE.value
|
marathon.status = MarathonStatus.ACTIVE.value
|
||||||
|
|
||||||
@@ -332,7 +367,7 @@ async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSes
|
|||||||
@router.post("/join", response_model=MarathonResponse)
|
@router.post("/join", response_model=MarathonResponse)
|
||||||
async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSession):
|
async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSession):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Marathon).where(Marathon.invite_code == data.invite_code)
|
select(Marathon).where(func.upper(Marathon.invite_code) == data.invite_code.upper())
|
||||||
)
|
)
|
||||||
marathon = result.scalar_one_or_none()
|
marathon = result.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -407,7 +442,16 @@ async def join_public_marathon(marathon_id: int, current_user: CurrentUser, db:
|
|||||||
|
|
||||||
@router.get("/{marathon_id}/participants", response_model=list[ParticipantWithUser])
|
@router.get("/{marathon_id}/participants", response_model=list[ParticipantWithUser])
|
||||||
async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def get_participants(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
await get_marathon_or_404(db, marathon_id)
|
marathon = await get_marathon_or_404(db, marathon_id)
|
||||||
|
|
||||||
|
# For private marathons, require participation (or admin)
|
||||||
|
if not marathon.is_public and not current_user.is_admin:
|
||||||
|
participation = await get_participation(db, current_user.id, marathon_id)
|
||||||
|
if not participation:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="You are not a participant of this private marathon",
|
||||||
|
)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Participant)
|
select(Participant)
|
||||||
@@ -476,8 +520,42 @@ async def set_participant_role(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{marathon_id}/leaderboard", response_model=list[LeaderboardEntry])
|
@router.get("/{marathon_id}/leaderboard", response_model=list[LeaderboardEntry])
|
||||||
async def get_leaderboard(marathon_id: int, db: DbSession):
|
async def get_leaderboard(
|
||||||
await get_marathon_or_404(db, marathon_id)
|
marathon_id: int,
|
||||||
|
db: DbSession,
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(optional_auth),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get marathon leaderboard.
|
||||||
|
Public marathons: no auth required.
|
||||||
|
Private marathons: requires auth + participation check.
|
||||||
|
"""
|
||||||
|
marathon = await get_marathon_or_404(db, marathon_id)
|
||||||
|
|
||||||
|
# For private marathons, require authentication and participation
|
||||||
|
if not marathon.is_public:
|
||||||
|
if not credentials:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Authentication required for private marathon leaderboard",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = decode_access_token(credentials.credentials)
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or expired token",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = int(payload.get("sub"))
|
||||||
|
participant = await get_participant(db, user_id, marathon_id)
|
||||||
|
if not participant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="You are not a participant of this marathon",
|
||||||
|
)
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Participant)
|
select(Participant)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.api.deps import DbSession, CurrentUser
|
from app.api.deps import DbSession, CurrentUser, BotSecretDep
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.security import create_telegram_link_token, verify_telegram_link_token
|
from app.core.security import create_telegram_link_token, verify_telegram_link_token
|
||||||
from app.models import User, Participant, Marathon, Assignment, Challenge, Event, Game
|
from app.models import User, Participant, Marathon, Assignment, Challenge, Event, Game
|
||||||
@@ -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}")
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ async def generate_link_token(current_user: CurrentUser):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/confirm-link", response_model=TelegramLinkResponse)
|
@router.post("/confirm-link", response_model=TelegramLinkResponse)
|
||||||
async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession):
|
async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession, _: BotSecretDep):
|
||||||
"""Confirm Telegram account linking (called by bot)."""
|
"""Confirm Telegram account linking (called by bot)."""
|
||||||
logger.info(f"[TG_CONFIRM] ========== CONFIRM LINK REQUEST ==========")
|
logger.info(f"[TG_CONFIRM] ========== CONFIRM LINK REQUEST ==========")
|
||||||
logger.info(f"[TG_CONFIRM] telegram_id: {data.telegram_id}")
|
logger.info(f"[TG_CONFIRM] telegram_id: {data.telegram_id}")
|
||||||
@@ -145,7 +145,7 @@ async def confirm_telegram_link(data: TelegramConfirmLink, db: DbSession):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/user/{telegram_id}", response_model=TelegramUserResponse | None)
|
@router.get("/user/{telegram_id}", response_model=TelegramUserResponse | None)
|
||||||
async def get_user_by_telegram_id(telegram_id: int, db: DbSession):
|
async def get_user_by_telegram_id(telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||||
"""Get user by Telegram ID."""
|
"""Get user by Telegram ID."""
|
||||||
logger.info(f"[TG_USER] Looking up user by telegram_id={telegram_id}")
|
logger.info(f"[TG_USER] Looking up user by telegram_id={telegram_id}")
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ async def get_user_by_telegram_id(telegram_id: int, db: DbSession):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/unlink/{telegram_id}", response_model=TelegramLinkResponse)
|
@router.post("/unlink/{telegram_id}", response_model=TelegramLinkResponse)
|
||||||
async def unlink_telegram(telegram_id: int, db: DbSession):
|
async def unlink_telegram(telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||||
"""Unlink Telegram account."""
|
"""Unlink Telegram account."""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(User).where(User.telegram_id == telegram_id)
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
@@ -187,7 +187,7 @@ async def unlink_telegram(telegram_id: int, db: DbSession):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/marathons/{telegram_id}", response_model=list[TelegramMarathonResponse])
|
@router.get("/marathons/{telegram_id}", response_model=list[TelegramMarathonResponse])
|
||||||
async def get_user_marathons(telegram_id: int, db: DbSession):
|
async def get_user_marathons(telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||||
"""Get user's marathons by Telegram ID."""
|
"""Get user's marathons by Telegram ID."""
|
||||||
# Get user
|
# Get user
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -231,7 +231,7 @@ async def get_user_marathons(telegram_id: int, db: DbSession):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/marathon/{marathon_id}", response_model=TelegramMarathonDetails | None)
|
@router.get("/marathon/{marathon_id}", response_model=TelegramMarathonDetails | None)
|
||||||
async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession):
|
async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||||
"""Get marathon details for user by Telegram ID."""
|
"""Get marathon details for user by Telegram ID."""
|
||||||
# Get user
|
# Get user
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -341,7 +341,7 @@ async def get_marathon_details(marathon_id: int, telegram_id: int, db: DbSession
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/stats/{telegram_id}", response_model=TelegramStatsResponse | None)
|
@router.get("/stats/{telegram_id}", response_model=TelegramStatsResponse | None)
|
||||||
async def get_user_stats(telegram_id: int, db: DbSession):
|
async def get_user_stats(telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||||
"""Get user's overall statistics by Telegram ID."""
|
"""Get user's overall statistics by Telegram ID."""
|
||||||
# Get user
|
# Get user
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from app.models import User, Participant, Assignment, Marathon
|
|||||||
from app.models.assignment import AssignmentStatus
|
from app.models.assignment import AssignmentStatus
|
||||||
from app.models.marathon import MarathonStatus
|
from app.models.marathon import MarathonStatus
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
UserPublic, UserUpdate, TelegramLink, MessageResponse,
|
UserPublic, UserPrivate, UserUpdate, TelegramLink, MessageResponse,
|
||||||
PasswordChange, UserStats, UserProfilePublic,
|
PasswordChange, UserStats, UserProfilePublic,
|
||||||
)
|
)
|
||||||
from app.services.storage import storage_service
|
from app.services.storage import storage_service
|
||||||
@@ -17,7 +17,8 @@ router = APIRouter(prefix="/users", tags=["users"])
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}", response_model=UserPublic)
|
@router.get("/{user_id}", response_model=UserPublic)
|
||||||
async def get_user(user_id: int, db: DbSession):
|
async def get_user(user_id: int, db: DbSession, current_user: CurrentUser):
|
||||||
|
"""Get user profile. Requires authentication."""
|
||||||
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()
|
||||||
|
|
||||||
@@ -58,23 +59,25 @@ async def get_user_avatar(user_id: int, db: DbSession):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/me", response_model=UserPublic)
|
@router.patch("/me", response_model=UserPrivate)
|
||||||
async def update_me(data: UserUpdate, current_user: CurrentUser, db: DbSession):
|
async def update_me(data: UserUpdate, current_user: CurrentUser, db: DbSession):
|
||||||
|
"""Update current user's profile"""
|
||||||
if data.nickname is not None:
|
if data.nickname is not None:
|
||||||
current_user.nickname = data.nickname
|
current_user.nickname = data.nickname
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(current_user)
|
await db.refresh(current_user)
|
||||||
|
|
||||||
return UserPublic.model_validate(current_user)
|
return UserPrivate.model_validate(current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/me/avatar", response_model=UserPublic)
|
@router.post("/me/avatar", response_model=UserPrivate)
|
||||||
async def upload_avatar(
|
async def upload_avatar(
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
):
|
):
|
||||||
|
"""Upload current user's avatar"""
|
||||||
# Validate file
|
# Validate file
|
||||||
if not file.content_type.startswith("image/"):
|
if not file.content_type.startswith("image/"):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -115,7 +118,7 @@ async def upload_avatar(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(current_user)
|
await db.refresh(current_user)
|
||||||
|
|
||||||
return UserPublic.model_validate(current_user)
|
return UserPrivate.model_validate(current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/me/telegram", response_model=MessageResponse)
|
@router.post("/me/telegram", response_model=MessageResponse)
|
||||||
@@ -193,8 +196,8 @@ async def get_my_stats(current_user: CurrentUser, db: DbSession):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}/stats", response_model=UserStats)
|
@router.get("/{user_id}/stats", response_model=UserStats)
|
||||||
async def get_user_stats(user_id: int, db: DbSession):
|
async def get_user_stats(user_id: int, db: DbSession, current_user: CurrentUser):
|
||||||
"""Получить статистику пользователя"""
|
"""Получить статистику пользователя. Requires authentication."""
|
||||||
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()
|
||||||
if not user:
|
if not user:
|
||||||
@@ -207,8 +210,8 @@ async def get_user_stats(user_id: int, db: DbSession):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}/profile", response_model=UserProfilePublic)
|
@router.get("/{user_id}/profile", response_model=UserProfilePublic)
|
||||||
async def get_user_profile(user_id: int, db: DbSession):
|
async def get_user_profile(user_id: int, db: DbSession, current_user: CurrentUser):
|
||||||
"""Получить публичный профиль пользователя со статистикой"""
|
"""Получить публичный профиль пользователя со статистикой. Requires authentication."""
|
||||||
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()
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class Settings(BaseSettings):
|
|||||||
TELEGRAM_BOT_TOKEN: str = ""
|
TELEGRAM_BOT_TOKEN: str = ""
|
||||||
TELEGRAM_BOT_USERNAME: str = ""
|
TELEGRAM_BOT_USERNAME: str = ""
|
||||||
TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES: int = 10
|
TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES: int = 10
|
||||||
|
BOT_API_SECRET: str = "" # Secret key for bot-to-backend communication
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
FRONTEND_URL: str = "http://localhost:3000"
|
FRONTEND_URL: str = "http://localhost:3000"
|
||||||
|
|||||||
5
backend/app/core/rate_limit.py
Normal file
5
backend/app/core/rate_limit.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from slowapi import Limiter
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
|
# Rate limiter using client IP address as key
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from slowapi import _rate_limit_exceeded_handler
|
||||||
|
from slowapi.errors import RateLimitExceeded
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -14,6 +17,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.database import engine, Base, async_session_maker
|
from app.core.database import engine, Base, async_session_maker
|
||||||
|
from app.core.rate_limit import limiter
|
||||||
from app.api.v1 import router as api_router
|
from app.api.v1 import router as api_router
|
||||||
from app.services.event_scheduler import event_scheduler
|
from app.services.event_scheduler import event_scheduler
|
||||||
from app.services.dispute_scheduler import dispute_scheduler
|
from app.services.dispute_scheduler import dispute_scheduler
|
||||||
@@ -49,6 +53,10 @@ app = FastAPI(
|
|||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
app.state.limiter = limiter
|
||||||
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
|
|
||||||
# CORS
|
# CORS
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ from app.models.activity import Activity, ActivityType
|
|||||||
from app.models.event import Event, EventType
|
from app.models.event import Event, EventType
|
||||||
from app.models.swap_request import SwapRequest, SwapRequestStatus
|
from app.models.swap_request import SwapRequest, SwapRequestStatus
|
||||||
from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVote
|
from app.models.dispute import Dispute, DisputeStatus, DisputeComment, DisputeVote
|
||||||
|
from app.models.admin_log import AdminLog, AdminActionType
|
||||||
|
from app.models.admin_2fa import Admin2FASession
|
||||||
|
from app.models.static_content import StaticContent
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@@ -35,4 +38,8 @@ __all__ = [
|
|||||||
"DisputeStatus",
|
"DisputeStatus",
|
||||||
"DisputeComment",
|
"DisputeComment",
|
||||||
"DisputeVote",
|
"DisputeVote",
|
||||||
|
"AdminLog",
|
||||||
|
"AdminActionType",
|
||||||
|
"Admin2FASession",
|
||||||
|
"StaticContent",
|
||||||
]
|
]
|
||||||
|
|||||||
20
backend/app/models/admin_2fa.py
Normal file
20
backend/app/models/admin_2fa.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, DateTime, Integer, ForeignKey, Boolean
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Admin2FASession(Base):
|
||||||
|
__tablename__ = "admin_2fa_sessions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(6), nullable=False)
|
||||||
|
telegram_sent: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
is_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user: Mapped["User"] = relationship("User", foreign_keys=[user_id])
|
||||||
47
backend/app/models/admin_log.py
Normal file
47
backend/app/models/admin_log.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from sqlalchemy import String, DateTime, Integer, ForeignKey, JSON
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AdminActionType(str, Enum):
|
||||||
|
# User actions
|
||||||
|
USER_BAN = "user_ban"
|
||||||
|
USER_UNBAN = "user_unban"
|
||||||
|
USER_AUTO_UNBAN = "user_auto_unban" # System automatic unban
|
||||||
|
USER_ROLE_CHANGE = "user_role_change"
|
||||||
|
USER_PASSWORD_RESET = "user_password_reset"
|
||||||
|
|
||||||
|
# Marathon actions
|
||||||
|
MARATHON_FORCE_FINISH = "marathon_force_finish"
|
||||||
|
MARATHON_DELETE = "marathon_delete"
|
||||||
|
|
||||||
|
# Content actions
|
||||||
|
CONTENT_UPDATE = "content_update"
|
||||||
|
|
||||||
|
# Broadcast actions
|
||||||
|
BROADCAST_ALL = "broadcast_all"
|
||||||
|
BROADCAST_MARATHON = "broadcast_marathon"
|
||||||
|
|
||||||
|
# Auth actions
|
||||||
|
ADMIN_LOGIN = "admin_login"
|
||||||
|
ADMIN_2FA_SUCCESS = "admin_2fa_success"
|
||||||
|
ADMIN_2FA_FAIL = "admin_2fa_fail"
|
||||||
|
|
||||||
|
|
||||||
|
class AdminLog(Base):
|
||||||
|
__tablename__ = "admin_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
admin_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True, index=True) # Nullable for system actions
|
||||||
|
action: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
target_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
target_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
details: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
ip_address: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
admin: Mapped["User"] = relationship("User", foreign_keys=[admin_id])
|
||||||
@@ -29,6 +29,12 @@ class ProofType(str, Enum):
|
|||||||
STEAM = "steam"
|
STEAM = "steam"
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengeStatus(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
APPROVED = "approved"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
|
||||||
|
|
||||||
class Challenge(Base):
|
class Challenge(Base):
|
||||||
__tablename__ = "challenges"
|
__tablename__ = "challenges"
|
||||||
|
|
||||||
@@ -45,8 +51,13 @@ class Challenge(Base):
|
|||||||
is_generated: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_generated: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Proposed challenges support
|
||||||
|
proposed_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="approved") # pending, approved, rejected
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
game: Mapped["Game"] = relationship("Game", back_populates="challenges")
|
game: Mapped["Game"] = relationship("Game", back_populates="challenges")
|
||||||
|
proposed_by: Mapped["User"] = relationship("User", foreign_keys=[proposed_by_id])
|
||||||
assignments: Mapped[list["Assignment"]] = relationship(
|
assignments: Mapped[list["Assignment"]] = relationship(
|
||||||
"Assignment",
|
"Assignment",
|
||||||
back_populates="challenge"
|
back_populates="challenge"
|
||||||
|
|||||||
20
backend/app/models/static_content.py
Normal file
20
backend/app/models/static_content.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, DateTime, Integer, ForeignKey, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class StaticContent(Base):
|
||||||
|
__tablename__ = "static_content"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||||
|
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
updated_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
updated_by: Mapped["User | None"] = relationship("User", foreign_keys=[updated_by_id])
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from sqlalchemy import String, BigInteger, DateTime
|
from sqlalchemy import String, BigInteger, DateTime, Boolean, ForeignKey, Integer
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -27,6 +27,13 @@ class User(Base):
|
|||||||
role: Mapped[str] = mapped_column(String(20), default=UserRole.USER.value)
|
role: Mapped[str] = mapped_column(String(20), default=UserRole.USER.value)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Ban fields
|
||||||
|
is_banned: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
banned_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
banned_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # None = permanent
|
||||||
|
banned_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
ban_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
created_marathons: Mapped[list["Marathon"]] = relationship(
|
created_marathons: Mapped[list["Marathon"]] = relationship(
|
||||||
"Marathon",
|
"Marathon",
|
||||||
@@ -47,6 +54,11 @@ class User(Base):
|
|||||||
back_populates="approved_by",
|
back_populates="approved_by",
|
||||||
foreign_keys="Game.approved_by_id"
|
foreign_keys="Game.approved_by_id"
|
||||||
)
|
)
|
||||||
|
banned_by: Mapped["User | None"] = relationship(
|
||||||
|
"User",
|
||||||
|
remote_side="User.id",
|
||||||
|
foreign_keys=[banned_by_id]
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_admin(self) -> bool:
|
def is_admin(self) -> bool:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from app.schemas.user import (
|
|||||||
UserLogin,
|
UserLogin,
|
||||||
UserUpdate,
|
UserUpdate,
|
||||||
UserPublic,
|
UserPublic,
|
||||||
UserWithTelegram,
|
UserPrivate,
|
||||||
TokenResponse,
|
TokenResponse,
|
||||||
TelegramLink,
|
TelegramLink,
|
||||||
PasswordChange,
|
PasswordChange,
|
||||||
@@ -37,6 +37,7 @@ from app.schemas.challenge import (
|
|||||||
ChallengesPreviewResponse,
|
ChallengesPreviewResponse,
|
||||||
ChallengeSaveItem,
|
ChallengeSaveItem,
|
||||||
ChallengesSaveRequest,
|
ChallengesSaveRequest,
|
||||||
|
ChallengesGenerateRequest,
|
||||||
)
|
)
|
||||||
from app.schemas.assignment import (
|
from app.schemas.assignment import (
|
||||||
CompleteAssignment,
|
CompleteAssignment,
|
||||||
@@ -80,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
|
||||||
@@ -87,7 +105,7 @@ __all__ = [
|
|||||||
"UserLogin",
|
"UserLogin",
|
||||||
"UserUpdate",
|
"UserUpdate",
|
||||||
"UserPublic",
|
"UserPublic",
|
||||||
"UserWithTelegram",
|
"UserPrivate",
|
||||||
"TokenResponse",
|
"TokenResponse",
|
||||||
"TelegramLink",
|
"TelegramLink",
|
||||||
"PasswordChange",
|
"PasswordChange",
|
||||||
@@ -118,6 +136,7 @@ __all__ = [
|
|||||||
"ChallengesPreviewResponse",
|
"ChallengesPreviewResponse",
|
||||||
"ChallengeSaveItem",
|
"ChallengeSaveItem",
|
||||||
"ChallengesSaveRequest",
|
"ChallengesSaveRequest",
|
||||||
|
"ChallengesGenerateRequest",
|
||||||
# Assignment
|
# Assignment
|
||||||
"CompleteAssignment",
|
"CompleteAssignment",
|
||||||
"AssignmentResponse",
|
"AssignmentResponse",
|
||||||
@@ -155,4 +174,20 @@ __all__ = [
|
|||||||
"DisputeResponse",
|
"DisputeResponse",
|
||||||
"AssignmentDetailResponse",
|
"AssignmentDetailResponse",
|
||||||
"ReturnedAssignmentResponse",
|
"ReturnedAssignmentResponse",
|
||||||
|
# Admin
|
||||||
|
"BanUserRequest",
|
||||||
|
"AdminResetPasswordRequest",
|
||||||
|
"AdminUserResponse",
|
||||||
|
"AdminLogResponse",
|
||||||
|
"AdminLogsListResponse",
|
||||||
|
"BroadcastRequest",
|
||||||
|
"BroadcastResponse",
|
||||||
|
"StaticContentResponse",
|
||||||
|
"StaticContentUpdate",
|
||||||
|
"StaticContentCreate",
|
||||||
|
"TwoFactorInitiateRequest",
|
||||||
|
"TwoFactorInitiateResponse",
|
||||||
|
"TwoFactorVerifyRequest",
|
||||||
|
"LoginResponse",
|
||||||
|
"DashboardStats",
|
||||||
]
|
]
|
||||||
|
|||||||
123
backend/app/schemas/admin.py
Normal file
123
backend/app/schemas/admin.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
# ============ User Ban ============
|
||||||
|
class BanUserRequest(BaseModel):
|
||||||
|
reason: str = Field(..., min_length=1, max_length=500)
|
||||||
|
banned_until: datetime | None = None # None = permanent ban
|
||||||
|
|
||||||
|
|
||||||
|
class AdminResetPasswordRequest(BaseModel):
|
||||||
|
new_password: str = Field(..., min_length=6, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
login: str
|
||||||
|
nickname: str
|
||||||
|
role: str
|
||||||
|
avatar_url: str | None = None
|
||||||
|
telegram_id: int | None = None
|
||||||
|
telegram_username: str | None = None
|
||||||
|
marathons_count: int = 0
|
||||||
|
created_at: str
|
||||||
|
is_banned: bool = False
|
||||||
|
banned_at: str | None = None
|
||||||
|
banned_until: str | None = None # None = permanent
|
||||||
|
ban_reason: str | None = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Admin Logs ============
|
||||||
|
class AdminLogResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
admin_id: int | None = None # Nullable for system actions
|
||||||
|
admin_nickname: str | None = None # Nullable for system actions
|
||||||
|
action: str
|
||||||
|
target_type: str
|
||||||
|
target_id: int
|
||||||
|
details: dict | None = None
|
||||||
|
ip_address: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class AdminLogsListResponse(BaseModel):
|
||||||
|
logs: list[AdminLogResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Broadcast ============
|
||||||
|
class BroadcastRequest(BaseModel):
|
||||||
|
message: str = Field(..., min_length=1, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class BroadcastResponse(BaseModel):
|
||||||
|
sent_count: int
|
||||||
|
total_count: int
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Static Content ============
|
||||||
|
class StaticContentResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
key: str
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
updated_at: datetime
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class StaticContentUpdate(BaseModel):
|
||||||
|
title: str = Field(..., min_length=1, max_length=200)
|
||||||
|
content: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class StaticContentCreate(BaseModel):
|
||||||
|
key: str = Field(..., min_length=1, max_length=100, pattern=r"^[a-z0-9_-]+$")
|
||||||
|
title: str = Field(..., min_length=1, max_length=200)
|
||||||
|
content: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ 2FA ============
|
||||||
|
class TwoFactorInitiateRequest(BaseModel):
|
||||||
|
pass # No additional data needed
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorInitiateResponse(BaseModel):
|
||||||
|
session_id: int
|
||||||
|
expires_at: datetime
|
||||||
|
message: str = "Code sent to Telegram"
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorVerifyRequest(BaseModel):
|
||||||
|
session_id: int
|
||||||
|
code: str = Field(..., min_length=6, max_length=6)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginResponse(BaseModel):
|
||||||
|
"""Login response that may require 2FA"""
|
||||||
|
access_token: str | None = None
|
||||||
|
token_type: str = "bearer"
|
||||||
|
user: Any = None # UserPrivate
|
||||||
|
requires_2fa: bool = False
|
||||||
|
two_factor_session_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Dashboard Stats ============
|
||||||
|
class DashboardStats(BaseModel):
|
||||||
|
users_count: int
|
||||||
|
banned_users_count: int
|
||||||
|
marathons_count: int
|
||||||
|
active_marathons_count: int
|
||||||
|
games_count: int
|
||||||
|
total_participations: int
|
||||||
|
recent_logs: list[AdminLogResponse] = []
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.models.challenge import ChallengeType, Difficulty, ProofType
|
from app.models.challenge import ChallengeType, Difficulty, ProofType, ChallengeStatus
|
||||||
from app.schemas.game import GameShort
|
from app.schemas.game import GameShort
|
||||||
|
|
||||||
|
|
||||||
|
class ProposedByUser(BaseModel):
|
||||||
|
"""Minimal user info for proposed challenges"""
|
||||||
|
id: int
|
||||||
|
nickname: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
class ChallengeBase(BaseModel):
|
class ChallengeBase(BaseModel):
|
||||||
title: str = Field(..., min_length=1, max_length=100)
|
title: str = Field(..., min_length=1, max_length=100)
|
||||||
description: str = Field(..., min_length=1)
|
description: str = Field(..., min_length=1)
|
||||||
@@ -36,11 +45,18 @@ class ChallengeResponse(ChallengeBase):
|
|||||||
game: GameShort
|
game: GameShort
|
||||||
is_generated: bool
|
is_generated: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
status: str = "approved"
|
||||||
|
proposed_by: ProposedByUser | None = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengePropose(ChallengeBase):
|
||||||
|
"""Schema for proposing a challenge by a participant"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ChallengeGenerated(BaseModel):
|
class ChallengeGenerated(BaseModel):
|
||||||
"""Schema for GPT-generated challenges"""
|
"""Schema for GPT-generated challenges"""
|
||||||
title: str
|
title: str
|
||||||
@@ -88,3 +104,8 @@ class ChallengeSaveItem(BaseModel):
|
|||||||
class ChallengesSaveRequest(BaseModel):
|
class ChallengesSaveRequest(BaseModel):
|
||||||
"""Request to save previewed challenges"""
|
"""Request to save previewed challenges"""
|
||||||
challenges: list[ChallengeSaveItem]
|
challenges: list[ChallengeSaveItem]
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengesGenerateRequest(BaseModel):
|
||||||
|
"""Request to generate challenges for specific games"""
|
||||||
|
game_ids: list[int] | None = None # If None, generate for all approved games without challenges
|
||||||
|
|||||||
@@ -29,30 +29,30 @@ class UserUpdate(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class UserPublic(UserBase):
|
class UserPublic(UserBase):
|
||||||
|
"""Public user info visible to other users - minimal data"""
|
||||||
id: int
|
id: int
|
||||||
login: str
|
|
||||||
avatar_url: str | None = None
|
avatar_url: str | None = None
|
||||||
role: str = "user"
|
role: str = "user"
|
||||||
telegram_id: int | None = None
|
telegram_avatar_url: str | None = None # Only TG avatar is public
|
||||||
telegram_username: str | None = None
|
|
||||||
telegram_first_name: str | None = None
|
|
||||||
telegram_last_name: str | None = None
|
|
||||||
telegram_avatar_url: str | None = None
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
class UserWithTelegram(UserPublic):
|
class UserPrivate(UserPublic):
|
||||||
|
"""Full user info visible only to the user themselves"""
|
||||||
|
login: str
|
||||||
telegram_id: int | None = None
|
telegram_id: int | None = None
|
||||||
telegram_username: str | None = None
|
telegram_username: str | None = None
|
||||||
|
telegram_first_name: str | None = None
|
||||||
|
telegram_last_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
user: UserPublic
|
user: UserPrivate
|
||||||
|
|
||||||
|
|
||||||
class TelegramLink(BaseModel):
|
class TelegramLink(BaseModel):
|
||||||
|
|||||||
@@ -244,6 +244,74 @@ class TelegramNotifier:
|
|||||||
)
|
)
|
||||||
return await self.notify_user(db, user_id, message)
|
return await self.notify_user(db, user_id, message)
|
||||||
|
|
||||||
|
async def notify_game_approved(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
marathon_title: str,
|
||||||
|
game_title: str
|
||||||
|
) -> bool:
|
||||||
|
"""Notify user that their proposed game was approved."""
|
||||||
|
message = (
|
||||||
|
f"✅ <b>Твоя игра одобрена!</b>\n\n"
|
||||||
|
f"Марафон: {marathon_title}\n"
|
||||||
|
f"Игра: {game_title}\n\n"
|
||||||
|
f"Теперь она доступна для всех участников."
|
||||||
|
)
|
||||||
|
return await self.notify_user(db, user_id, message)
|
||||||
|
|
||||||
|
async def notify_game_rejected(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
marathon_title: str,
|
||||||
|
game_title: str
|
||||||
|
) -> bool:
|
||||||
|
"""Notify user that their proposed game was rejected."""
|
||||||
|
message = (
|
||||||
|
f"❌ <b>Твоя игра отклонена</b>\n\n"
|
||||||
|
f"Марафон: {marathon_title}\n"
|
||||||
|
f"Игра: {game_title}\n\n"
|
||||||
|
f"Ты можешь предложить другую игру."
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
|||||||
@@ -31,5 +31,8 @@ python-magic==0.4.27
|
|||||||
# S3 Storage
|
# S3 Storage
|
||||||
boto3==1.34.0
|
boto3==1.34.0
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
slowapi==0.1.9
|
||||||
|
|
||||||
# Utils
|
# Utils
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
|
|||||||
30
backup-service/Dockerfile
Normal file
30
backup-service/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install PostgreSQL client (for pg_dump and psql) and cron
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
postgresql-client \
|
||||||
|
cron \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Make scripts executable
|
||||||
|
RUN chmod +x backup.py restore.py
|
||||||
|
|
||||||
|
# Setup cron
|
||||||
|
COPY crontab /etc/cron.d/backup-cron
|
||||||
|
RUN chmod 0644 /etc/cron.d/backup-cron
|
||||||
|
RUN crontab /etc/cron.d/backup-cron
|
||||||
|
|
||||||
|
# Create log file
|
||||||
|
RUN touch /var/log/cron.log
|
||||||
|
|
||||||
|
# Start cron in foreground and tail logs
|
||||||
|
CMD ["sh", "-c", "printenv > /etc/environment && cron && tail -f /var/log/cron.log"]
|
||||||
217
backup-service/backup.py
Normal file
217
backup-service/backup.py
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
PostgreSQL Backup Service for WebApp.
|
||||||
|
|
||||||
|
- Creates pg_dump backup
|
||||||
|
- Compresses with gzip
|
||||||
|
- Uploads to S3 FirstVDS
|
||||||
|
- Rotates old backups (configurable retention)
|
||||||
|
- Sends Telegram notifications
|
||||||
|
"""
|
||||||
|
import gzip
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
import httpx
|
||||||
|
from botocore.config import Config as BotoConfig
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
|
||||||
|
def create_s3_client():
|
||||||
|
"""Initialize S3 client (same pattern as backend storage.py)."""
|
||||||
|
return boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=config.S3_ENDPOINT_URL,
|
||||||
|
aws_access_key_id=config.S3_ACCESS_KEY_ID,
|
||||||
|
aws_secret_access_key=config.S3_SECRET_ACCESS_KEY,
|
||||||
|
region_name=config.S3_REGION or "us-east-1",
|
||||||
|
config=BotoConfig(signature_version="s3v4"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_telegram_notification(message: str, is_error: bool = False) -> None:
|
||||||
|
"""Send notification to Telegram admin."""
|
||||||
|
if not config.TELEGRAM_BOT_TOKEN or not config.TELEGRAM_ADMIN_ID:
|
||||||
|
print("Telegram not configured, skipping notification")
|
||||||
|
return
|
||||||
|
|
||||||
|
emoji = "\u274c" if is_error else "\u2705"
|
||||||
|
text = f"{emoji} *Database Backup*\n\n{message}"
|
||||||
|
|
||||||
|
url = f"https://api.telegram.org/bot{config.TELEGRAM_BOT_TOKEN}/sendMessage"
|
||||||
|
data = {
|
||||||
|
"chat_id": config.TELEGRAM_ADMIN_ID,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "Markdown",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = httpx.post(url, json=data, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
print("Telegram notification sent")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to send Telegram notification: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def create_backup() -> tuple[str, bytes]:
|
||||||
|
"""Create pg_dump backup and compress it."""
|
||||||
|
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"marathon_backup_{timestamp}.sql.gz"
|
||||||
|
|
||||||
|
# Build pg_dump command
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PGPASSWORD"] = config.DB_PASSWORD
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"pg_dump",
|
||||||
|
"-h",
|
||||||
|
config.DB_HOST,
|
||||||
|
"-p",
|
||||||
|
config.DB_PORT,
|
||||||
|
"-U",
|
||||||
|
config.DB_USER,
|
||||||
|
"-d",
|
||||||
|
config.DB_NAME,
|
||||||
|
"--no-owner",
|
||||||
|
"--no-acl",
|
||||||
|
"-F",
|
||||||
|
"p", # plain SQL format
|
||||||
|
]
|
||||||
|
|
||||||
|
print(f"Running pg_dump for database {config.DB_NAME}...")
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise Exception(f"pg_dump failed: {result.stderr.decode()}")
|
||||||
|
|
||||||
|
# Compress the output
|
||||||
|
print("Compressing backup...")
|
||||||
|
compressed = gzip.compress(result.stdout, compresslevel=9)
|
||||||
|
|
||||||
|
return filename, compressed
|
||||||
|
|
||||||
|
|
||||||
|
def upload_to_s3(s3_client, filename: str, data: bytes) -> str:
|
||||||
|
"""Upload backup to S3."""
|
||||||
|
key = f"{config.S3_BACKUP_PREFIX}{filename}"
|
||||||
|
|
||||||
|
print(f"Uploading to S3: {key}...")
|
||||||
|
s3_client.put_object(
|
||||||
|
Bucket=config.S3_BUCKET_NAME,
|
||||||
|
Key=key,
|
||||||
|
Body=data,
|
||||||
|
ContentType="application/gzip",
|
||||||
|
)
|
||||||
|
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_old_backups(s3_client) -> int:
|
||||||
|
"""Delete backups older than BACKUP_RETENTION_DAYS."""
|
||||||
|
cutoff_date = datetime.now(timezone.utc) - timedelta(
|
||||||
|
days=config.BACKUP_RETENTION_DAYS
|
||||||
|
)
|
||||||
|
deleted_count = 0
|
||||||
|
|
||||||
|
print(f"Rotating backups older than {config.BACKUP_RETENTION_DAYS} days...")
|
||||||
|
|
||||||
|
# List all objects with backup prefix
|
||||||
|
try:
|
||||||
|
paginator = s3_client.get_paginator("list_objects_v2")
|
||||||
|
pages = paginator.paginate(
|
||||||
|
Bucket=config.S3_BUCKET_NAME,
|
||||||
|
Prefix=config.S3_BACKUP_PREFIX,
|
||||||
|
)
|
||||||
|
|
||||||
|
for page in pages:
|
||||||
|
for obj in page.get("Contents", []):
|
||||||
|
last_modified = obj["LastModified"]
|
||||||
|
if last_modified.tzinfo is None:
|
||||||
|
last_modified = last_modified.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
if last_modified < cutoff_date:
|
||||||
|
s3_client.delete_object(
|
||||||
|
Bucket=config.S3_BUCKET_NAME,
|
||||||
|
Key=obj["Key"],
|
||||||
|
)
|
||||||
|
deleted_count += 1
|
||||||
|
print(f"Deleted old backup: {obj['Key']}")
|
||||||
|
except ClientError as e:
|
||||||
|
print(f"Error during rotation: {e}")
|
||||||
|
|
||||||
|
return deleted_count
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Main backup routine."""
|
||||||
|
start_time = datetime.now()
|
||||||
|
|
||||||
|
print(f"{'=' * 50}")
|
||||||
|
print(f"Backup started at {start_time}")
|
||||||
|
print(f"{'=' * 50}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Validate configuration
|
||||||
|
if not config.S3_BUCKET_NAME:
|
||||||
|
raise Exception("S3_BUCKET_NAME is not configured")
|
||||||
|
if not config.S3_ACCESS_KEY_ID:
|
||||||
|
raise Exception("S3_ACCESS_KEY_ID is not configured")
|
||||||
|
if not config.S3_SECRET_ACCESS_KEY:
|
||||||
|
raise Exception("S3_SECRET_ACCESS_KEY is not configured")
|
||||||
|
if not config.S3_ENDPOINT_URL:
|
||||||
|
raise Exception("S3_ENDPOINT_URL is not configured")
|
||||||
|
|
||||||
|
# Create S3 client
|
||||||
|
s3_client = create_s3_client()
|
||||||
|
|
||||||
|
# Create backup
|
||||||
|
filename, data = create_backup()
|
||||||
|
size_mb = len(data) / (1024 * 1024)
|
||||||
|
print(f"Backup created: {filename} ({size_mb:.2f} MB)")
|
||||||
|
|
||||||
|
# Upload to S3
|
||||||
|
s3_key = upload_to_s3(s3_client, filename, data)
|
||||||
|
print(f"Uploaded to S3: {s3_key}")
|
||||||
|
|
||||||
|
# Rotate old backups
|
||||||
|
deleted_count = rotate_old_backups(s3_client)
|
||||||
|
print(f"Deleted {deleted_count} old backups")
|
||||||
|
|
||||||
|
# Calculate duration
|
||||||
|
duration = datetime.now() - start_time
|
||||||
|
|
||||||
|
# Send success notification
|
||||||
|
message = (
|
||||||
|
f"Backup completed successfully!\n\n"
|
||||||
|
f"*File:* `{filename}`\n"
|
||||||
|
f"*Size:* {size_mb:.2f} MB\n"
|
||||||
|
f"*Duration:* {duration.seconds}s\n"
|
||||||
|
f"*Deleted old:* {deleted_count} files"
|
||||||
|
)
|
||||||
|
send_telegram_notification(message, is_error=False)
|
||||||
|
|
||||||
|
print(f"{'=' * 50}")
|
||||||
|
print("Backup completed successfully!")
|
||||||
|
print(f"{'=' * 50}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Backup failed!\n\n*Error:* `{str(e)}`"
|
||||||
|
send_telegram_notification(error_msg, is_error=True)
|
||||||
|
print(f"{'=' * 50}")
|
||||||
|
print(f"Backup failed: {e}")
|
||||||
|
print(f"{'=' * 50}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
33
backup-service/config.py
Normal file
33
backup-service/config.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""Configuration for backup service."""
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
"""Backup service configuration from environment variables."""
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_HOST: str = os.getenv("DB_HOST", "db")
|
||||||
|
DB_PORT: str = os.getenv("DB_PORT", "5432")
|
||||||
|
DB_NAME: str = os.getenv("DB_NAME", "marathon")
|
||||||
|
DB_USER: str = os.getenv("DB_USER", "marathon")
|
||||||
|
DB_PASSWORD: str = os.getenv("DB_PASSWORD", "123")
|
||||||
|
|
||||||
|
# S3
|
||||||
|
S3_BUCKET_NAME: str = os.getenv("S3_BUCKET_NAME", "")
|
||||||
|
S3_REGION: str = os.getenv("S3_REGION", "ru-1")
|
||||||
|
S3_ACCESS_KEY_ID: str = os.getenv("S3_ACCESS_KEY_ID", "")
|
||||||
|
S3_SECRET_ACCESS_KEY: str = os.getenv("S3_SECRET_ACCESS_KEY", "")
|
||||||
|
S3_ENDPOINT_URL: str = os.getenv("S3_ENDPOINT_URL", "")
|
||||||
|
S3_BACKUP_PREFIX: str = os.getenv("S3_BACKUP_PREFIX", "backups/")
|
||||||
|
|
||||||
|
# Telegram
|
||||||
|
TELEGRAM_BOT_TOKEN: str = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||||||
|
TELEGRAM_ADMIN_ID: str = os.getenv("TELEGRAM_ADMIN_ID", "947392854")
|
||||||
|
|
||||||
|
# Backup settings
|
||||||
|
BACKUP_RETENTION_DAYS: int = int(os.getenv("BACKUP_RETENTION_DAYS", "14"))
|
||||||
|
|
||||||
|
|
||||||
|
config = Config()
|
||||||
4
backup-service/crontab
Normal file
4
backup-service/crontab
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Backup cron job
|
||||||
|
# Run backup daily at 3:00 AM UTC
|
||||||
|
0 3 * * * /usr/local/bin/python /app/backup.py >> /var/log/cron.log 2>&1
|
||||||
|
# Empty line required at end of crontab
|
||||||
2
backup-service/requirements.txt
Normal file
2
backup-service/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
boto3==1.34.0
|
||||||
|
httpx==0.26.0
|
||||||
158
backup-service/restore.py
Normal file
158
backup-service/restore.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Restore PostgreSQL database from S3 backup.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python restore.py - List available backups
|
||||||
|
python restore.py <filename> - Restore from specific backup
|
||||||
|
"""
|
||||||
|
import gzip
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.config import Config as BotoConfig
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
from config import config
|
||||||
|
|
||||||
|
|
||||||
|
def create_s3_client():
|
||||||
|
"""Initialize S3 client."""
|
||||||
|
return boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=config.S3_ENDPOINT_URL,
|
||||||
|
aws_access_key_id=config.S3_ACCESS_KEY_ID,
|
||||||
|
aws_secret_access_key=config.S3_SECRET_ACCESS_KEY,
|
||||||
|
region_name=config.S3_REGION or "us-east-1",
|
||||||
|
config=BotoConfig(signature_version="s3v4"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_backups(s3_client) -> list[tuple[str, float, str]]:
|
||||||
|
"""List all available backups."""
|
||||||
|
print("Available backups:\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
paginator = s3_client.get_paginator("list_objects_v2")
|
||||||
|
pages = paginator.paginate(
|
||||||
|
Bucket=config.S3_BUCKET_NAME,
|
||||||
|
Prefix=config.S3_BACKUP_PREFIX,
|
||||||
|
)
|
||||||
|
|
||||||
|
backups = []
|
||||||
|
for page in pages:
|
||||||
|
for obj in page.get("Contents", []):
|
||||||
|
filename = obj["Key"].replace(config.S3_BACKUP_PREFIX, "")
|
||||||
|
size_mb = obj["Size"] / (1024 * 1024)
|
||||||
|
modified = obj["LastModified"].strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
backups.append((filename, size_mb, modified))
|
||||||
|
|
||||||
|
# Sort by date descending (newest first)
|
||||||
|
backups.sort(key=lambda x: x[2], reverse=True)
|
||||||
|
|
||||||
|
for filename, size_mb, modified in backups:
|
||||||
|
print(f" {filename} ({size_mb:.2f} MB) - {modified}")
|
||||||
|
|
||||||
|
return backups
|
||||||
|
|
||||||
|
except ClientError as e:
|
||||||
|
print(f"Error listing backups: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def restore_backup(s3_client, filename: str) -> None:
|
||||||
|
"""Download and restore backup."""
|
||||||
|
key = f"{config.S3_BACKUP_PREFIX}{filename}"
|
||||||
|
|
||||||
|
print(f"Downloading {filename} from S3...")
|
||||||
|
try:
|
||||||
|
response = s3_client.get_object(
|
||||||
|
Bucket=config.S3_BUCKET_NAME,
|
||||||
|
Key=key,
|
||||||
|
)
|
||||||
|
compressed_data = response["Body"].read()
|
||||||
|
except ClientError as e:
|
||||||
|
raise Exception(f"Failed to download backup: {e}")
|
||||||
|
|
||||||
|
print("Decompressing...")
|
||||||
|
sql_data = gzip.decompress(compressed_data)
|
||||||
|
|
||||||
|
print(f"Restoring to database {config.DB_NAME}...")
|
||||||
|
|
||||||
|
# Build psql command
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PGPASSWORD"] = config.DB_PASSWORD
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"psql",
|
||||||
|
"-h",
|
||||||
|
config.DB_HOST,
|
||||||
|
"-p",
|
||||||
|
config.DB_PORT,
|
||||||
|
"-U",
|
||||||
|
config.DB_USER,
|
||||||
|
"-d",
|
||||||
|
config.DB_NAME,
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
env=env,
|
||||||
|
input=sql_data,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
stderr = result.stderr.decode()
|
||||||
|
# psql may return warnings that aren't fatal errors
|
||||||
|
if "ERROR" in stderr:
|
||||||
|
raise Exception(f"psql restore failed: {stderr}")
|
||||||
|
else:
|
||||||
|
print(f"Warnings: {stderr}")
|
||||||
|
|
||||||
|
print("Restore completed successfully!")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Main restore routine."""
|
||||||
|
# Validate configuration
|
||||||
|
if not config.S3_BUCKET_NAME:
|
||||||
|
print("Error: S3_BUCKET_NAME is not configured")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
s3_client = create_s3_client()
|
||||||
|
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
# List available backups
|
||||||
|
backups = list_backups(s3_client)
|
||||||
|
if backups:
|
||||||
|
print(f"\nTo restore, run: python restore.py <filename>")
|
||||||
|
else:
|
||||||
|
print("No backups found.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
filename = sys.argv[1]
|
||||||
|
|
||||||
|
# Confirm restore
|
||||||
|
print(f"WARNING: This will restore database from {filename}")
|
||||||
|
print("This may overwrite existing data!")
|
||||||
|
print()
|
||||||
|
|
||||||
|
confirm = input("Type 'yes' to continue: ")
|
||||||
|
|
||||||
|
if confirm.lower() != "yes":
|
||||||
|
print("Restore cancelled.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
restore_backup(s3_client, filename)
|
||||||
|
return 0
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Restore failed: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -5,6 +5,7 @@ class Settings(BaseSettings):
|
|||||||
TELEGRAM_BOT_TOKEN: str
|
TELEGRAM_BOT_TOKEN: str
|
||||||
API_URL: str = "http://backend:8000"
|
API_URL: str = "http://backend:8000"
|
||||||
BOT_USERNAME: str = "" # Will be set dynamically on startup
|
BOT_USERNAME: str = "" # Will be set dynamically on startup
|
||||||
|
BOT_API_SECRET: str = "" # Secret for backend API communication
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
|||||||
35
bot/main.py
35
bot/main.py
@@ -5,6 +5,7 @@ import sys
|
|||||||
from aiogram import Bot, Dispatcher
|
from aiogram import Bot, Dispatcher
|
||||||
from aiogram.client.default import DefaultBotProperties
|
from aiogram.client.default import DefaultBotProperties
|
||||||
from aiogram.enums import ParseMode
|
from aiogram.enums import ParseMode
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
from handlers import start, marathons, link
|
from handlers import start, marathons, link
|
||||||
@@ -23,14 +24,41 @@ logger = logging.getLogger(__name__)
|
|||||||
# Set aiogram logging level
|
# Set aiogram logging level
|
||||||
logging.getLogger("aiogram").setLevel(logging.INFO)
|
logging.getLogger("aiogram").setLevel(logging.INFO)
|
||||||
|
|
||||||
|
# Health check state
|
||||||
|
bot_running = False
|
||||||
|
|
||||||
|
|
||||||
|
async def health_handler(request):
|
||||||
|
"""Health check endpoint"""
|
||||||
|
if bot_running:
|
||||||
|
return web.json_response({"status": "ok", "service": "telegram-bot"})
|
||||||
|
return web.json_response({"status": "starting"}, status=503)
|
||||||
|
|
||||||
|
|
||||||
|
async def start_health_server():
|
||||||
|
"""Start health check HTTP server"""
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_get("/health", health_handler)
|
||||||
|
runner = web.AppRunner(app)
|
||||||
|
await runner.setup()
|
||||||
|
site = web.TCPSite(runner, "0.0.0.0", 8080)
|
||||||
|
await site.start()
|
||||||
|
logger.info("Health check server started on port 8080")
|
||||||
|
return runner
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
|
global bot_running
|
||||||
|
|
||||||
logger.info("="*50)
|
logger.info("="*50)
|
||||||
logger.info("Starting Game Marathon Bot...")
|
logger.info("Starting Game Marathon Bot...")
|
||||||
logger.info(f"API_URL: {settings.API_URL}")
|
logger.info(f"API_URL: {settings.API_URL}")
|
||||||
logger.info(f"BOT_TOKEN: {settings.TELEGRAM_BOT_TOKEN[:20]}...")
|
logger.info(f"BOT_TOKEN: {settings.TELEGRAM_BOT_TOKEN[:20]}...")
|
||||||
logger.info("="*50)
|
logger.info("="*50)
|
||||||
|
|
||||||
|
# Start health check server
|
||||||
|
health_runner = await start_health_server()
|
||||||
|
|
||||||
bot = Bot(
|
bot = Bot(
|
||||||
token=settings.TELEGRAM_BOT_TOKEN,
|
token=settings.TELEGRAM_BOT_TOKEN,
|
||||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
|
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
|
||||||
@@ -54,11 +82,18 @@ async def main():
|
|||||||
dp.include_router(marathons.router)
|
dp.include_router(marathons.router)
|
||||||
logger.info("Routers registered: start, link, marathons")
|
logger.info("Routers registered: start, link, marathons")
|
||||||
|
|
||||||
|
# Mark bot as running
|
||||||
|
bot_running = True
|
||||||
|
|
||||||
# Start polling
|
# Start polling
|
||||||
logger.info("Deleting webhook and starting polling...")
|
logger.info("Deleting webhook and starting polling...")
|
||||||
await bot.delete_webhook(drop_pending_updates=True)
|
await bot.delete_webhook(drop_pending_updates=True)
|
||||||
logger.info("Polling started! Waiting for messages...")
|
logger.info("Polling started! Waiting for messages...")
|
||||||
|
try:
|
||||||
await dp.start_polling(bot)
|
await dp.start_polling(bot)
|
||||||
|
finally:
|
||||||
|
bot_running = False
|
||||||
|
await health_runner.cleanup()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ class APIClient:
|
|||||||
session = await self._get_session()
|
session = await self._get_session()
|
||||||
url = f"{self.base_url}/api/v1{endpoint}"
|
url = f"{self.base_url}/api/v1{endpoint}"
|
||||||
|
|
||||||
|
# Add bot secret header for authentication
|
||||||
|
headers = kwargs.pop("headers", {})
|
||||||
|
if settings.BOT_API_SECRET:
|
||||||
|
headers["X-Bot-Secret"] = settings.BOT_API_SECRET
|
||||||
|
|
||||||
logger.info(f"[APIClient] {method} {url}")
|
logger.info(f"[APIClient] {method} {url}")
|
||||||
if 'json' in kwargs:
|
if 'json' in kwargs:
|
||||||
logger.info(f"[APIClient] Request body: {kwargs['json']}")
|
logger.info(f"[APIClient] Request body: {kwargs['json']}")
|
||||||
@@ -39,7 +44,7 @@ class APIClient:
|
|||||||
logger.info(f"[APIClient] Request params: {kwargs['params']}")
|
logger.info(f"[APIClient] Request params: {kwargs['params']}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with session.request(method, url, **kwargs) as response:
|
async with session.request(method, url, headers=headers, **kwargs) as response:
|
||||||
logger.info(f"[APIClient] Response status: {response.status}")
|
logger.info(f"[APIClient] Response status: {response.status}")
|
||||||
response_text = await response.text()
|
response_text = await response.text()
|
||||||
logger.info(f"[APIClient] Response body: {response_text[:500]}")
|
logger.info(f"[APIClient] Response body: {response_text[:500]}")
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ 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:-}
|
||||||
DEBUG: ${DEBUG:-false}
|
DEBUG: ${DEBUG:-false}
|
||||||
# S3 Storage
|
# S3 Storage
|
||||||
S3_ENABLED: ${S3_ENABLED:-false}
|
S3_ENABLED: ${S3_ENABLED:-false}
|
||||||
@@ -81,9 +82,60 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||||
- API_URL=http://backend:8000
|
- API_URL=http://backend:8000
|
||||||
|
- BOT_API_SECRET=${BOT_API_SECRET:-}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
status:
|
||||||
|
build:
|
||||||
|
context: ./status-service
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: marathon-status
|
||||||
|
environment:
|
||||||
|
BACKEND_URL: http://backend:8000
|
||||||
|
FRONTEND_URL: http://frontend:80
|
||||||
|
BOT_URL: http://bot:8080
|
||||||
|
EXTERNAL_URL: ${EXTERNAL_URL:-}
|
||||||
|
PUBLIC_URL: ${PUBLIC_URL:-}
|
||||||
|
CHECK_INTERVAL: "30"
|
||||||
|
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
||||||
|
TELEGRAM_ADMIN_ID: ${TELEGRAM_ADMIN_ID:-947392854}
|
||||||
|
volumes:
|
||||||
|
- status_data:/app/data
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
- frontend
|
||||||
|
- bot
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backup:
|
||||||
|
build:
|
||||||
|
context: ./backup-service
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: marathon-backup
|
||||||
|
environment:
|
||||||
|
DB_HOST: db
|
||||||
|
DB_PORT: "5432"
|
||||||
|
DB_NAME: marathon
|
||||||
|
DB_USER: marathon
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD:-marathon}
|
||||||
|
S3_BUCKET_NAME: ${S3_BUCKET_NAME:-}
|
||||||
|
S3_REGION: ${S3_REGION:-ru-1}
|
||||||
|
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
||||||
|
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
||||||
|
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-}
|
||||||
|
S3_BACKUP_PREFIX: ${S3_BACKUP_PREFIX:-backups/}
|
||||||
|
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN}
|
||||||
|
TELEGRAM_ADMIN_ID: ${TELEGRAM_ADMIN_ID:-947392854}
|
||||||
|
BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-14}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
status_data:
|
||||||
|
|||||||
BIN
frontend/public/telegram_banner.png
Normal file
BIN
frontend/public/telegram_banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
frontend/public/telegram_bot_banner.png
Normal file
BIN
frontend/public/telegram_bot_banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -1,6 +1,8 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { ToastContainer, ConfirmModal } from '@/components/ui'
|
import { ToastContainer, ConfirmModal } from '@/components/ui'
|
||||||
|
import { BannedScreen } from '@/components/BannedScreen'
|
||||||
|
|
||||||
// Layout
|
// Layout
|
||||||
import { Layout } from '@/components/layout/Layout'
|
import { Layout } from '@/components/layout/Layout'
|
||||||
@@ -19,7 +21,21 @@ 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 { 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 }) {
|
||||||
@@ -44,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 />
|
||||||
@@ -55,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={
|
||||||
@@ -148,6 +188,32 @@ function App() {
|
|||||||
|
|
||||||
<Route path="users/:id" element={<UserProfilePage />} />
|
<Route path="users/:id" element={<UserProfilePage />} />
|
||||||
|
|
||||||
|
{/* Easter egg - 418 I'm a teapot */}
|
||||||
|
<Route path="418" element={<TeapotPage />} />
|
||||||
|
<Route path="teapot" element={<TeapotPage />} />
|
||||||
|
<Route path="tea" element={<TeapotPage />} />
|
||||||
|
|
||||||
|
{/* Server error page */}
|
||||||
|
<Route path="500" element={<ServerErrorPage />} />
|
||||||
|
<Route path="error" element={<ServerErrorPage />} />
|
||||||
|
|
||||||
|
{/* Admin routes */}
|
||||||
|
<Route
|
||||||
|
path="admin"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AdminLayout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<AdminDashboardPage />} />
|
||||||
|
<Route path="users" element={<AdminUsersPage />} />
|
||||||
|
<Route path="marathons" element={<AdminMarathonsPage />} />
|
||||||
|
<Route path="logs" element={<AdminLogsPage />} />
|
||||||
|
<Route path="broadcast" element={<AdminBroadcastPage />} />
|
||||||
|
<Route path="content" element={<AdminContentPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
{/* 404 - must be last */}
|
{/* 404 - must be last */}
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
import client from './client'
|
import client from './client'
|
||||||
import type { AdminUser, AdminMarathon, UserRole, PlatformStats } from '@/types'
|
import type {
|
||||||
|
AdminUser,
|
||||||
|
AdminMarathon,
|
||||||
|
UserRole,
|
||||||
|
PlatformStats,
|
||||||
|
AdminLogsResponse,
|
||||||
|
BroadcastResponse,
|
||||||
|
StaticContent,
|
||||||
|
DashboardStats
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
|
// Dashboard
|
||||||
|
getDashboard: async (): Promise<DashboardStats> => {
|
||||||
|
const response = await client.get<DashboardStats>('/admin/dashboard')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
listUsers: async (skip = 0, limit = 50, search?: string): Promise<AdminUser[]> => {
|
listUsers: async (skip = 0, limit = 50, search?: string, bannedOnly = false): Promise<AdminUser[]> => {
|
||||||
const params: Record<string, unknown> = { skip, limit }
|
const params: Record<string, unknown> = { skip, limit, banned_only: bannedOnly }
|
||||||
if (search) params.search = search
|
if (search) params.search = search
|
||||||
const response = await client.get<AdminUser[]>('/admin/users', { params })
|
const response = await client.get<AdminUser[]>('/admin/users', { params })
|
||||||
return response.data
|
return response.data
|
||||||
@@ -24,6 +39,26 @@ export const adminApi = {
|
|||||||
await client.delete(`/admin/users/${id}`)
|
await client.delete(`/admin/users/${id}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
banUser: async (id: number, reason: string, bannedUntil?: string): Promise<AdminUser> => {
|
||||||
|
const response = await client.post<AdminUser>(`/admin/users/${id}/ban`, {
|
||||||
|
reason,
|
||||||
|
banned_until: bannedUntil || null,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
unbanUser: async (id: number): Promise<AdminUser> => {
|
||||||
|
const response = await client.post<AdminUser>(`/admin/users/${id}/unban`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
resetUserPassword: async (id: number, newPassword: string): Promise<AdminUser> => {
|
||||||
|
const response = await client.post<AdminUser>(`/admin/users/${id}/reset-password`, {
|
||||||
|
new_password: newPassword,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
// Marathons
|
// Marathons
|
||||||
listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => {
|
listMarathons: async (skip = 0, limit = 50, search?: string): Promise<AdminMarathon[]> => {
|
||||||
const params: Record<string, unknown> = { skip, limit }
|
const params: Record<string, unknown> = { skip, limit }
|
||||||
@@ -36,9 +71,66 @@ export const adminApi = {
|
|||||||
await client.delete(`/admin/marathons/${id}`)
|
await client.delete(`/admin/marathons/${id}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
forceFinishMarathon: async (id: number): Promise<void> => {
|
||||||
|
await client.post(`/admin/marathons/${id}/force-finish`)
|
||||||
|
},
|
||||||
|
|
||||||
// Stats
|
// Stats
|
||||||
getStats: async (): Promise<PlatformStats> => {
|
getStats: async (): Promise<PlatformStats> => {
|
||||||
const response = await client.get<PlatformStats>('/admin/stats')
|
const response = await client.get<PlatformStats>('/admin/stats')
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Logs
|
||||||
|
getLogs: async (skip = 0, limit = 50, action?: string, adminId?: number): Promise<AdminLogsResponse> => {
|
||||||
|
const params: Record<string, unknown> = { skip, limit }
|
||||||
|
if (action) params.action = action
|
||||||
|
if (adminId) params.admin_id = adminId
|
||||||
|
const response = await client.get<AdminLogsResponse>('/admin/logs', { params })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Broadcast
|
||||||
|
broadcastToAll: async (message: string): Promise<BroadcastResponse> => {
|
||||||
|
const response = await client.post<BroadcastResponse>('/admin/broadcast/all', { message })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
broadcastToMarathon: async (marathonId: number, message: string): Promise<BroadcastResponse> => {
|
||||||
|
const response = await client.post<BroadcastResponse>(`/admin/broadcast/marathon/${marathonId}`, { message })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Static Content
|
||||||
|
listContent: async (): Promise<StaticContent[]> => {
|
||||||
|
const response = await client.get<StaticContent[]>('/admin/content')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getContent: async (key: string): Promise<StaticContent> => {
|
||||||
|
const response = await client.get<StaticContent>(`/admin/content/${key}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
updateContent: async (key: string, title: string, content: string): Promise<StaticContent> => {
|
||||||
|
const response = await client.put<StaticContent>(`/admin/content/${key}`, { title, content })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
createContent: async (key: string, title: string, content: string): Promise<StaticContent> => {
|
||||||
|
const response = await client.post<StaticContent>('/admin/content', { key, title, content })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteContent: async (key: string): Promise<void> => {
|
||||||
|
await client.delete(`/admin/content/${key}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public content API (no auth required)
|
||||||
|
export const contentApi = {
|
||||||
|
getPublicContent: async (key: string): Promise<StaticContent> => {
|
||||||
|
const response = await client.get<StaticContent>(`/content/${key}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import client from './client'
|
import client from './client'
|
||||||
import type { TokenResponse, User } from '@/types'
|
import type { TokenResponse, LoginResponse, User } from '@/types'
|
||||||
|
|
||||||
export interface RegisterData {
|
export interface RegisterData {
|
||||||
login: string
|
login: string
|
||||||
@@ -18,8 +18,15 @@ export const authApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
login: async (data: LoginData): Promise<TokenResponse> => {
|
login: async (data: LoginData): Promise<LoginResponse> => {
|
||||||
const response = await client.post<TokenResponse>('/auth/login', data)
|
const response = await client.post<LoginResponse>('/auth/login', data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
verify2FA: async (sessionId: number, code: string): Promise<TokenResponse> => {
|
||||||
|
const response = await client.post<TokenResponse>('/auth/2fa/verify', null, {
|
||||||
|
params: { session_id: sessionId, code }
|
||||||
|
})
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios, { AxiosError } from 'axios'
|
import axios, { AxiosError } from 'axios'
|
||||||
|
import { useAuthStore, type BanInfo } from '@/store/auth'
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
||||||
|
|
||||||
@@ -18,15 +19,56 @@ 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 (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
|
||||||
|
if (
|
||||||
|
error.response?.status === 500 ||
|
||||||
|
error.response?.status === 502 ||
|
||||||
|
error.response?.status === 503 ||
|
||||||
|
error.response?.status === 504 ||
|
||||||
|
error.code === 'ERR_NETWORK' ||
|
||||||
|
error.code === 'ECONNABORTED'
|
||||||
|
) {
|
||||||
|
// Only redirect if not already on error page
|
||||||
|
if (!window.location.pathname.startsWith('/500') && !window.location.pathname.startsWith('/error')) {
|
||||||
|
window.location.href = '/500'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -79,8 +79,14 @@ export const gamesApi = {
|
|||||||
await client.delete(`/challenges/${id}`)
|
await client.delete(`/challenges/${id}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
previewChallenges: async (marathonId: number): Promise<ChallengesPreviewResponse> => {
|
updateChallenge: async (id: number, data: Partial<CreateChallengeData>): Promise<Challenge> => {
|
||||||
const response = await client.post<ChallengesPreviewResponse>(`/marathons/${marathonId}/preview-challenges`)
|
const response = await client.patch<Challenge>(`/challenges/${id}`, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
previewChallenges: async (marathonId: number, gameIds?: number[]): Promise<ChallengesPreviewResponse> => {
|
||||||
|
const data = gameIds?.length ? { game_ids: gameIds } : undefined
|
||||||
|
const response = await client.post<ChallengesPreviewResponse>(`/marathons/${marathonId}/preview-challenges`, data)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -88,4 +94,30 @@ export const gamesApi = {
|
|||||||
const response = await client.post<{ message: string }>(`/marathons/${marathonId}/save-challenges`, { challenges })
|
const response = await client.post<{ message: string }>(`/marathons/${marathonId}/save-challenges`, { challenges })
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Proposed challenges
|
||||||
|
proposeChallenge: async (gameId: number, data: CreateChallengeData): Promise<Challenge> => {
|
||||||
|
const response = await client.post<Challenge>(`/games/${gameId}/propose-challenge`, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getProposedChallenges: async (marathonId: number): Promise<Challenge[]> => {
|
||||||
|
const response = await client.get<Challenge[]>(`/marathons/${marathonId}/proposed-challenges`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getMyProposedChallenges: async (marathonId: number): Promise<Challenge[]> => {
|
||||||
|
const response = await client.get<Challenge[]>(`/marathons/${marathonId}/my-proposed-challenges`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
approveChallenge: async (id: number): Promise<Challenge> => {
|
||||||
|
const response = await client.patch<Challenge>(`/challenges/${id}/approve`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
rejectChallenge: async (id: number): Promise<Challenge> => {
|
||||||
|
const response = await client.patch<Challenge>(`/challenges/${id}/reject`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export { marathonsApi } from './marathons'
|
|||||||
export { gamesApi } from './games'
|
export { gamesApi } from './games'
|
||||||
export { wheelApi } from './wheel'
|
export { wheelApi } from './wheel'
|
||||||
export { feedApi } from './feed'
|
export { feedApi } from './feed'
|
||||||
export { adminApi } from './admin'
|
export { adminApi, contentApi } from './admin'
|
||||||
export { eventsApi } from './events'
|
export { eventsApi } from './events'
|
||||||
export { challengesApi } from './challenges'
|
export { challengesApi } from './challenges'
|
||||||
export { assignmentsApi } from './assignments'
|
export { assignmentsApi } from './assignments'
|
||||||
|
|||||||
@@ -41,8 +41,9 @@ export const usersApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Получить аватар пользователя как blob URL
|
// Получить аватар пользователя как blob URL
|
||||||
getAvatarUrl: async (userId: number): Promise<string> => {
|
getAvatarUrl: async (userId: number, bustCache = false): Promise<string> => {
|
||||||
const response = await client.get(`/users/${userId}/avatar`, {
|
const cacheBuster = bustCache ? `?t=${Date.now()}` : ''
|
||||||
|
const response = await client.get(`/users/${userId}/avatar${cacheBuster}`, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
})
|
})
|
||||||
return URL.createObjectURL(response.data)
|
return URL.createObjectURL(response.data)
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react'
|
import { useState, useEffect, useCallback, useRef, useImperativeHandle, forwardRef } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
import { feedApi } from '@/api'
|
import { feedApi } from '@/api'
|
||||||
import type { Activity, ActivityType } from '@/types'
|
import type { Activity, ActivityType } from '@/types'
|
||||||
import { Loader2, ChevronDown, Bell, ExternalLink, AlertTriangle } from 'lucide-react'
|
import { Loader2, ChevronDown, Activity as ActivityIcon, ExternalLink, AlertTriangle, Sparkles, Zap } from 'lucide-react'
|
||||||
import { UserAvatar } from '@/components/ui'
|
import { UserAvatar } from '@/components/ui'
|
||||||
import {
|
import {
|
||||||
formatRelativeTime,
|
formatRelativeTime,
|
||||||
getActivityIcon,
|
getActivityIcon,
|
||||||
getActivityColor,
|
getActivityColor,
|
||||||
getActivityBgClass,
|
|
||||||
isEventActivity,
|
isEventActivity,
|
||||||
formatActivityMessage,
|
formatActivityMessage,
|
||||||
} from '@/utils/activity'
|
} from '@/utils/activity'
|
||||||
@@ -100,52 +99,66 @@ export const ActivityFeed = forwardRef<ActivityFeedRef, ActivityFeedProps>(
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={`bg-gray-800/50 rounded-xl border border-gray-700/50 p-4 flex flex-col ${className}`}>
|
<div className={`glass rounded-2xl border border-dark-600 flex flex-col ${className}`}>
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-3 px-5 py-4 border-b border-dark-600">
|
||||||
<Bell className="w-5 h-5 text-primary-500" />
|
<div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center">
|
||||||
<h3 className="font-medium text-white">Активность</h3>
|
<ActivityIcon className="w-4 h-4 text-neon-400" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<h3 className="font-semibold text-white">Активность</h3>
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
|
</div>
|
||||||
|
<div className="flex-1 flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-neon-500" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`bg-gray-800/50 rounded-xl border border-gray-700/50 flex flex-col ${className}`}>
|
<div className={`glass rounded-2xl border border-dark-600 flex flex-col overflow-hidden ${className}`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700/50 flex-shrink-0">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-dark-600 flex-shrink-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<Bell className="w-5 h-5 text-primary-500" />
|
<div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center">
|
||||||
<h3 className="font-medium text-white">Активность</h3>
|
<Zap className="w-4 h-4 text-neon-400" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">Активность</h3>
|
||||||
{total > 0 && (
|
{total > 0 && (
|
||||||
<span className="text-xs text-gray-500">{total}</span>
|
<p className="text-xs text-gray-500">{total} событий</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-2 h-2 rounded-full bg-neon-500 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Activity list */}
|
{/* Activity list */}
|
||||||
<div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar">
|
<div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar">
|
||||||
{activities.length === 0 ? (
|
{activities.length === 0 ? (
|
||||||
<div className="px-4 py-8 text-center text-gray-500 text-sm">
|
<div className="px-5 py-12 text-center">
|
||||||
Пока нет активности
|
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-dark-700 flex items-center justify-center">
|
||||||
|
<Sparkles className="w-6 h-6 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 text-sm">Пока нет активности</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-gray-700/30">
|
<div className="divide-y divide-dark-600/50">
|
||||||
{activities.map((activity) => (
|
{activities.map((activity, index) => (
|
||||||
<ActivityItem key={activity.id} activity={activity} />
|
<ActivityItem
|
||||||
|
key={activity.id}
|
||||||
|
activity={activity}
|
||||||
|
isNew={index === 0}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Load more button */}
|
{/* Load more button */}
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<div className="p-3 border-t border-gray-700/30">
|
<div className="p-4 border-t border-dark-600/50">
|
||||||
<button
|
<button
|
||||||
onClick={handleLoadMore}
|
onClick={handleLoadMore}
|
||||||
disabled={isLoadingMore}
|
disabled={isLoadingMore}
|
||||||
className="w-full py-2 text-sm text-gray-400 hover:text-white transition-colors flex items-center justify-center gap-2"
|
className="w-full py-2.5 text-sm text-gray-400 hover:text-neon-400 transition-colors flex items-center justify-center gap-2 rounded-lg hover:bg-neon-500/5"
|
||||||
>
|
>
|
||||||
{isLoadingMore ? (
|
{isLoadingMore ? (
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
@@ -168,13 +181,13 @@ ActivityFeed.displayName = 'ActivityFeed'
|
|||||||
|
|
||||||
interface ActivityItemProps {
|
interface ActivityItemProps {
|
||||||
activity: Activity
|
activity: Activity
|
||||||
|
isNew?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActivityItem({ activity }: ActivityItemProps) {
|
function ActivityItem({ activity, isNew }: ActivityItemProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const Icon = getActivityIcon(activity.type)
|
const Icon = getActivityIcon(activity.type)
|
||||||
const iconColor = getActivityColor(activity.type)
|
const iconColor = getActivityColor(activity.type)
|
||||||
const bgClass = getActivityBgClass(activity.type)
|
|
||||||
const isEvent = isEventActivity(activity.type)
|
const isEvent = isEventActivity(activity.type)
|
||||||
const { title, details, extra } = formatActivityMessage(activity)
|
const { title, details, extra } = formatActivityMessage(activity)
|
||||||
|
|
||||||
@@ -187,21 +200,58 @@ function ActivityItem({ activity }: ActivityItemProps) {
|
|||||||
? activityData.dispute_status
|
? activityData.dispute_status
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
// Determine accent color based on activity type
|
||||||
|
const getAccentConfig = () => {
|
||||||
|
switch (activity.type) {
|
||||||
|
case 'spin':
|
||||||
|
return { border: 'border-l-accent-500', bg: 'bg-accent-500/5' }
|
||||||
|
case 'complete':
|
||||||
|
return { border: 'border-l-green-500', bg: 'bg-green-500/5' }
|
||||||
|
case 'drop':
|
||||||
|
return { border: 'border-l-red-500', bg: 'bg-red-500/5' }
|
||||||
|
case 'start_marathon':
|
||||||
|
case 'event_start':
|
||||||
|
return { border: 'border-l-yellow-500', bg: 'bg-yellow-500/5' }
|
||||||
|
case 'finish_marathon':
|
||||||
|
case 'event_end':
|
||||||
|
return { border: 'border-l-gray-500', bg: 'bg-gray-500/5' }
|
||||||
|
case 'swap':
|
||||||
|
case 'rematch':
|
||||||
|
return { border: 'border-l-neon-500', bg: 'bg-neon-500/5' }
|
||||||
|
default:
|
||||||
|
return { border: 'border-l-dark-600', bg: '' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accent = getAccentConfig()
|
||||||
|
|
||||||
if (isEvent) {
|
if (isEvent) {
|
||||||
return (
|
return (
|
||||||
<div className={`px-4 py-3 ${bgClass} border-l-2 ${activity.type === 'event_start' ? 'border-l-yellow-500' : 'border-l-gray-600'}`}>
|
<div className={`
|
||||||
<div className="flex items-center gap-2 mb-1">
|
px-5 py-4 border-l-2 transition-colors
|
||||||
<Icon className={`w-4 h-4 ${iconColor}`} />
|
${accent.border} ${accent.bg}
|
||||||
<span className={`text-sm font-medium ${activity.type === 'event_start' ? 'text-yellow-400' : 'text-gray-400'}`}>
|
hover:bg-dark-700/30
|
||||||
|
`}>
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<div className={`w-6 h-6 rounded-md flex items-center justify-center ${
|
||||||
|
activity.type === 'event_start' ? 'bg-yellow-500/20' : 'bg-gray-500/20'
|
||||||
|
}`}>
|
||||||
|
<Icon className={`w-3.5 h-3.5 ${iconColor}`} />
|
||||||
|
</div>
|
||||||
|
<span className={`text-sm font-semibold ${
|
||||||
|
activity.type === 'event_start' ? 'text-yellow-400' : 'text-gray-400'
|
||||||
|
}`}>
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{details && (
|
{details && (
|
||||||
<div className={`text-sm ${activity.type === 'event_start' ? 'text-yellow-200' : 'text-gray-500'}`}>
|
<div className={`text-sm ml-8 ${
|
||||||
|
activity.type === 'event_start' ? 'text-yellow-200/80' : 'text-gray-500'
|
||||||
|
}`}>
|
||||||
{details}
|
{details}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
<div className="text-xs text-gray-600 mt-2 ml-8">
|
||||||
{formatRelativeTime(activity.created_at)}
|
{formatRelativeTime(activity.created_at)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,39 +259,57 @@ function ActivityItem({ activity }: ActivityItemProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`px-4 py-3 hover:bg-gray-700/20 transition-colors ${bgClass}`}>
|
<div className={`
|
||||||
|
px-5 py-4 border-l-2 transition-all duration-200
|
||||||
|
${accent.border} ${isNew ? accent.bg : ''}
|
||||||
|
hover:bg-dark-700/30 group
|
||||||
|
`}>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<div className="flex-shrink-0">
|
<Link to={`/users/${activity.user.id}`} className="flex-shrink-0 relative" onClick={(e) => e.stopPropagation()}>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
userId={activity.user.id}
|
userId={activity.user.id}
|
||||||
hasAvatar={!!activity.user.avatar_url}
|
hasAvatar={!!activity.user.avatar_url}
|
||||||
nickname={activity.user.nickname}
|
nickname={activity.user.nickname}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
{/* Activity type badge */}
|
||||||
|
<div className={`
|
||||||
|
absolute -bottom-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center
|
||||||
|
border-2 border-dark-800
|
||||||
|
${activity.type === 'complete' ? 'bg-green-500' :
|
||||||
|
activity.type === 'drop' ? 'bg-red-500' :
|
||||||
|
activity.type === 'spin' ? 'bg-accent-500' :
|
||||||
|
'bg-neon-500'}
|
||||||
|
`}>
|
||||||
|
<Icon className="w-2.5 h-2.5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-sm font-medium text-white truncate">
|
<Link
|
||||||
|
to={`/users/${activity.user.id}`}
|
||||||
|
className="text-sm font-semibold text-white hover:text-neon-400 transition-colors"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
{activity.user.nickname}
|
{activity.user.nickname}
|
||||||
</span>
|
</Link>
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-600">
|
||||||
{formatRelativeTime(activity.created_at)}
|
{formatRelativeTime(activity.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 mt-0.5">
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
<Icon className={`w-3.5 h-3.5 flex-shrink-0 ${iconColor}`} />
|
|
||||||
<span className="text-sm text-gray-300">{title}</span>
|
<span className="text-sm text-gray-300">{title}</span>
|
||||||
</div>
|
</div>
|
||||||
{details && (
|
{details && (
|
||||||
<div className="text-sm text-gray-400 mt-1">
|
<div className="text-sm text-gray-500 mt-1">
|
||||||
{details}
|
{details}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{extra && (
|
{extra && (
|
||||||
<div className="text-xs text-gray-500 mt-0.5">
|
<div className="text-xs text-gray-600 mt-1">
|
||||||
{extra}
|
{extra}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -250,19 +318,19 @@ function ActivityItem({ activity }: ActivityItemProps) {
|
|||||||
<div className="flex items-center gap-3 mt-2">
|
<div className="flex items-center gap-3 mt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/assignments/${assignmentId}`)}
|
onClick={() => navigate(`/assignments/${assignmentId}`)}
|
||||||
className="text-xs text-primary-400 hover:text-primary-300 flex items-center gap-1"
|
className="text-xs text-neon-400 hover:text-neon-300 flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-neon-500/10 transition-colors"
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-3 h-3" />
|
<ExternalLink className="w-3 h-3" />
|
||||||
Детали
|
Детали
|
||||||
</button>
|
</button>
|
||||||
{disputeStatus === 'open' && (
|
{disputeStatus === 'open' && (
|
||||||
<span className="text-xs text-orange-400 flex items-center gap-1">
|
<span className="text-xs text-orange-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-orange-500/10">
|
||||||
<AlertTriangle className="w-3 h-3" />
|
<AlertTriangle className="w-3 h-3" />
|
||||||
Оспаривается
|
Оспаривается
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{disputeStatus === 'valid' && (
|
{disputeStatus === 'valid' && (
|
||||||
<span className="text-xs text-red-400 flex items-center gap-1">
|
<span className="text-xs text-red-400 flex items-center gap-1.5 px-2 py-1 rounded-md bg-red-500/10">
|
||||||
<AlertTriangle className="w-3 h-3" />
|
<AlertTriangle className="w-3 h-3" />
|
||||||
Отклонено
|
Отклонено
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
78
frontend/src/components/AnnouncementBanner.tsx
Normal file
78
frontend/src/components/AnnouncementBanner.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { contentApi } from '@/api/admin'
|
||||||
|
import { Megaphone, X } from 'lucide-react'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'announcement_dismissed'
|
||||||
|
|
||||||
|
export function AnnouncementBanner() {
|
||||||
|
const [content, setContent] = useState<string | null>(null)
|
||||||
|
const [title, setTitle] = useState<string | null>(null)
|
||||||
|
const [updatedAt, setUpdatedAt] = useState<string | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAnnouncement = async () => {
|
||||||
|
try {
|
||||||
|
const data = await contentApi.getPublicContent('announcement')
|
||||||
|
// Check if this announcement was already dismissed (by updated_at)
|
||||||
|
const dismissedAt = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (dismissedAt === data.updated_at) {
|
||||||
|
setContent(null)
|
||||||
|
} else {
|
||||||
|
setContent(data.content)
|
||||||
|
setTitle(data.title)
|
||||||
|
setUpdatedAt(data.updated_at)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// No announcement or error - don't show
|
||||||
|
setContent(null)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAnnouncement()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
if (updatedAt) {
|
||||||
|
// Store the updated_at to know which announcement was dismissed
|
||||||
|
// When admin updates announcement, updated_at changes and banner shows again
|
||||||
|
localStorage.setItem(STORAGE_KEY, updatedAt)
|
||||||
|
setContent(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading || !content) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative rounded-xl overflow-hidden bg-gradient-to-r from-accent-500/20 via-purple-500/20 to-pink-500/20 border border-accent-500/30">
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
className="absolute top-3 right-3 p-1.5 text-white bg-dark-700/70 hover:bg-dark-600 rounded-lg transition-colors z-10"
|
||||||
|
title="Скрыть"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-4 pr-12 flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-accent-500/20 border border-accent-500/30 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Megaphone className="w-5 h-5 text-accent-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{title && (
|
||||||
|
<h3 className="font-semibold text-white mb-1">{title}</h3>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="text-sm text-gray-300"
|
||||||
|
dangerouslySetInnerHTML={{ __html: content }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
130
frontend/src/components/BannedScreen.tsx
Normal file
130
frontend/src/components/BannedScreen.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { Ban, LogOut, Calendar, Clock, AlertTriangle, Sparkles } from 'lucide-react'
|
||||||
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { NeonButton } from '@/components/ui'
|
||||||
|
|
||||||
|
interface BanInfo {
|
||||||
|
banned_at: string | null
|
||||||
|
banned_until: string | null
|
||||||
|
reason: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BannedScreenProps {
|
||||||
|
banInfo: BanInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null) {
|
||||||
|
if (!dateStr) return null
|
||||||
|
return new Date(dateStr).toLocaleString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: 'Europe/Moscow',
|
||||||
|
}) + ' (МСК)'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BannedScreen({ banInfo }: BannedScreenProps) {
|
||||||
|
const logout = useAuthStore((state) => state.logout)
|
||||||
|
|
||||||
|
const bannedAtFormatted = formatDate(banInfo.banned_at)
|
||||||
|
const bannedUntilFormatted = formatDate(banInfo.banned_until)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-dark-900 flex flex-col items-center justify-center text-center px-4">
|
||||||
|
{/* Background effects */}
|
||||||
|
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<div className="absolute top-1/4 -left-32 w-96 h-96 bg-red-500/5 rounded-full blur-[100px]" />
|
||||||
|
<div className="absolute bottom-1/4 -right-32 w-96 h-96 bg-orange-500/5 rounded-full blur-[100px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="relative mb-8">
|
||||||
|
<div className="w-32 h-32 rounded-3xl bg-dark-700/50 border-2 border-red-500/30 flex items-center justify-center shadow-[0_0_30px_rgba(239,68,68,0.2)]">
|
||||||
|
<Ban className="w-16 h-16 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute -bottom-2 -right-2 w-12 h-12 rounded-xl bg-red-500/20 border border-red-500/40 flex items-center justify-center animate-pulse">
|
||||||
|
<AlertTriangle className="w-6 h-6 text-red-400" />
|
||||||
|
</div>
|
||||||
|
{/* Decorative dots */}
|
||||||
|
<div className="absolute -top-2 -left-2 w-3 h-3 rounded-full bg-red-500/50 animate-pulse" />
|
||||||
|
<div className="absolute top-4 -right-4 w-2 h-2 rounded-full bg-orange-500/50 animate-pulse" style={{ animationDelay: '0.5s' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title with glow */}
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<h1 className="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-red-400 via-orange-400 to-red-400">
|
||||||
|
Аккаунт заблокирован
|
||||||
|
</h1>
|
||||||
|
<div className="absolute inset-0 text-4xl font-bold text-red-500/20 blur-xl">
|
||||||
|
Аккаунт заблокирован
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-400 mb-8 max-w-md">
|
||||||
|
Ваш доступ к платформе был ограничен администрацией.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Ban Info Card */}
|
||||||
|
<div className="glass rounded-2xl p-6 mb-8 max-w-md w-full border border-red-500/20 text-left space-y-4">
|
||||||
|
{bannedAtFormatted && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-dark-700/50">
|
||||||
|
<Calendar className="w-5 h-5 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wider">Дата блокировки</p>
|
||||||
|
<p className="text-white font-medium">{bannedAtFormatted}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-dark-700/50">
|
||||||
|
<Clock className="w-5 h-5 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wider">Срок</p>
|
||||||
|
<p className={`font-medium ${bannedUntilFormatted ? 'text-amber-400' : 'text-red-400'}`}>
|
||||||
|
{bannedUntilFormatted ? `до ${bannedUntilFormatted}` : 'Навсегда'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{banInfo.reason && (
|
||||||
|
<div className="pt-4 border-t border-dark-600">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Причина</p>
|
||||||
|
<p className="text-white bg-dark-700/50 rounded-xl p-4 border border-dark-600">
|
||||||
|
{banInfo.reason}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info text */}
|
||||||
|
<p className="text-gray-500 text-sm mb-8 max-w-md">
|
||||||
|
{banInfo.banned_until
|
||||||
|
? 'Ваш аккаунт будет автоматически разблокирован по истечении срока.'
|
||||||
|
: 'Если вы считаете, что блокировка ошибочна, обратитесь к администрации.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Logout button */}
|
||||||
|
<NeonButton
|
||||||
|
variant="secondary"
|
||||||
|
size="lg"
|
||||||
|
onClick={logout}
|
||||||
|
icon={<LogOut className="w-5 h-5" />}
|
||||||
|
>
|
||||||
|
Выйти из аккаунта
|
||||||
|
</NeonButton>
|
||||||
|
|
||||||
|
{/* Decorative sparkles */}
|
||||||
|
<div className="absolute top-1/4 left-1/4 opacity-20">
|
||||||
|
<Sparkles className="w-6 h-6 text-red-400 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-1/3 right-1/4 opacity-20">
|
||||||
|
<Sparkles className="w-4 h-4 text-orange-400 animate-pulse" style={{ animationDelay: '1s' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Clock } from 'lucide-react'
|
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Clock, Sparkles } from 'lucide-react'
|
||||||
import type { ActiveEvent, EventType } from '@/types'
|
import type { ActiveEvent, EventType } from '@/types'
|
||||||
import { EVENT_INFO } from '@/types'
|
import { EVENT_INFO } from '@/types'
|
||||||
|
|
||||||
@@ -17,13 +17,55 @@ const EVENT_ICONS: Record<EventType, React.ReactNode> = {
|
|||||||
game_choice: <Gamepad2 className="w-5 h-5" />,
|
game_choice: <Gamepad2 className="w-5 h-5" />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const EVENT_COLORS: Record<EventType, string> = {
|
const EVENT_COLORS: Record<EventType, {
|
||||||
golden_hour: 'from-yellow-500/20 to-yellow-600/20 border-yellow-500/50 text-yellow-400',
|
gradient: string
|
||||||
common_enemy: 'from-red-500/20 to-red-600/20 border-red-500/50 text-red-400',
|
border: string
|
||||||
double_risk: 'from-purple-500/20 to-purple-600/20 border-purple-500/50 text-purple-400',
|
text: string
|
||||||
jackpot: 'from-green-500/20 to-green-600/20 border-green-500/50 text-green-400',
|
glow: string
|
||||||
swap: 'from-blue-500/20 to-blue-600/20 border-blue-500/50 text-blue-400',
|
iconBg: string
|
||||||
game_choice: 'from-orange-500/20 to-orange-600/20 border-orange-500/50 text-orange-400',
|
}> = {
|
||||||
|
golden_hour: {
|
||||||
|
gradient: 'from-yellow-500/20 via-yellow-500/10 to-transparent',
|
||||||
|
border: 'border-yellow-500/50',
|
||||||
|
text: 'text-yellow-400',
|
||||||
|
glow: 'shadow-[0_0_30px_rgba(234,179,8,0.3)]',
|
||||||
|
iconBg: 'bg-yellow-500/20',
|
||||||
|
},
|
||||||
|
common_enemy: {
|
||||||
|
gradient: 'from-red-500/20 via-red-500/10 to-transparent',
|
||||||
|
border: 'border-red-500/50',
|
||||||
|
text: 'text-red-400',
|
||||||
|
glow: 'shadow-[0_0_30px_rgba(239,68,68,0.3)]',
|
||||||
|
iconBg: 'bg-red-500/20',
|
||||||
|
},
|
||||||
|
double_risk: {
|
||||||
|
gradient: 'from-purple-500/20 via-purple-500/10 to-transparent',
|
||||||
|
border: 'border-purple-500/50',
|
||||||
|
text: 'text-purple-400',
|
||||||
|
glow: 'shadow-[0_0_20px_rgba(139,92,246,0.25)]',
|
||||||
|
iconBg: 'bg-purple-500/20',
|
||||||
|
},
|
||||||
|
jackpot: {
|
||||||
|
gradient: 'from-green-500/20 via-green-500/10 to-transparent',
|
||||||
|
border: 'border-green-500/50',
|
||||||
|
text: 'text-green-400',
|
||||||
|
glow: 'shadow-[0_0_30px_rgba(34,197,94,0.3)]',
|
||||||
|
iconBg: 'bg-green-500/20',
|
||||||
|
},
|
||||||
|
swap: {
|
||||||
|
gradient: 'from-neon-500/20 via-neon-500/10 to-transparent',
|
||||||
|
border: 'border-neon-500/50',
|
||||||
|
text: 'text-neon-400',
|
||||||
|
glow: 'shadow-[0_0_20px_rgba(34,211,238,0.25)]',
|
||||||
|
iconBg: 'bg-neon-500/20',
|
||||||
|
},
|
||||||
|
game_choice: {
|
||||||
|
gradient: 'from-orange-500/20 via-orange-500/10 to-transparent',
|
||||||
|
border: 'border-orange-500/50',
|
||||||
|
text: 'text-orange-400',
|
||||||
|
glow: 'shadow-[0_0_30px_rgba(249,115,22,0.3)]',
|
||||||
|
iconBg: 'bg-orange-500/20',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatTime(seconds: number): string {
|
||||||
@@ -68,42 +110,53 @@ export function EventBanner({ activeEvent, onRefresh }: EventBannerProps) {
|
|||||||
const event = activeEvent.event
|
const event = activeEvent.event
|
||||||
const info = EVENT_INFO[event.type]
|
const info = EVENT_INFO[event.type]
|
||||||
const icon = EVENT_ICONS[event.type]
|
const icon = EVENT_ICONS[event.type]
|
||||||
const colorClass = EVENT_COLORS[event.type]
|
const colors = EVENT_COLORS[event.type]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
relative overflow-hidden rounded-xl border p-4
|
relative overflow-hidden rounded-2xl border p-5
|
||||||
bg-gradient-to-r ${colorClass}
|
glass ${colors.border} ${colors.glow}
|
||||||
animate-pulse-slow
|
animate-pulse-slow
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{/* Animated background effect */}
|
{/* Background gradient */}
|
||||||
|
<div className={`absolute inset-0 bg-gradient-to-r ${colors.gradient}`} />
|
||||||
|
|
||||||
|
{/* Animated shimmer effect */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent -translate-x-full animate-shimmer" />
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent -translate-x-full animate-shimmer" />
|
||||||
|
|
||||||
<div className="relative flex items-center justify-between">
|
{/* Grid pattern */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.02)_1px,transparent_1px)] bg-[size:20px_20px]" />
|
||||||
<div className="p-2 rounded-lg bg-white/10">
|
|
||||||
|
<div className="relative flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={`p-3 rounded-xl ${colors.iconBg} ${colors.text}`}>
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-bold text-lg">{info.name}</h3>
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<p className="text-sm opacity-80">{info.description}</p>
|
<h3 className={`font-bold text-lg ${colors.text}`}>{info.name}</h3>
|
||||||
|
<Sparkles className={`w-4 h-4 ${colors.text} animate-pulse`} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-400">{info.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{activeEvent.effects.points_multiplier !== 1.0 && (
|
||||||
|
<div className={`px-4 py-2 rounded-xl ${colors.iconBg} font-bold ${colors.text} border ${colors.border}`}>
|
||||||
|
x{activeEvent.effects.points_multiplier}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{timeRemaining !== null && timeRemaining > 0 && (
|
{timeRemaining !== null && timeRemaining > 0 && (
|
||||||
<div className="flex items-center gap-2 text-lg font-mono font-bold">
|
<div className={`flex items-center gap-2 px-4 py-2 rounded-xl bg-dark-700/50 border border-dark-600 font-mono font-bold ${colors.text}`}>
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
{formatTime(timeRemaining)}
|
{formatTime(timeRemaining)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeEvent.effects.points_multiplier !== 1.0 && (
|
|
||||||
<div className="px-3 py-1 rounded-full bg-white/10 font-bold">
|
|
||||||
x{activeEvent.effects.points_multiplier}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Play, Square } from 'lucide-react'
|
import { Zap, Users, Shield, Gift, ArrowLeftRight, Gamepad2, Play, Square, Sparkles } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui'
|
import { NeonButton } from '@/components/ui'
|
||||||
import { eventsApi } from '@/api'
|
import { eventsApi } from '@/api'
|
||||||
import type { ActiveEvent, EventType, Challenge } from '@/types'
|
import type { ActiveEvent, EventType, Challenge } from '@/types'
|
||||||
import { EVENT_INFO } from '@/types'
|
import { EVENT_INFO } from '@/types'
|
||||||
@@ -24,12 +24,21 @@ const EVENT_TYPES: EventType[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const EVENT_ICONS: Record<EventType, React.ReactNode> = {
|
const EVENT_ICONS: Record<EventType, React.ReactNode> = {
|
||||||
golden_hour: <Zap className="w-4 h-4" />,
|
golden_hour: <Zap className="w-5 h-5" />,
|
||||||
common_enemy: <Users className="w-4 h-4" />,
|
common_enemy: <Users className="w-5 h-5" />,
|
||||||
double_risk: <Shield className="w-4 h-4" />,
|
double_risk: <Shield className="w-5 h-5" />,
|
||||||
jackpot: <Gift className="w-4 h-4" />,
|
jackpot: <Gift className="w-5 h-5" />,
|
||||||
swap: <ArrowLeftRight className="w-4 h-4" />,
|
swap: <ArrowLeftRight className="w-5 h-5" />,
|
||||||
game_choice: <Gamepad2 className="w-4 h-4" />,
|
game_choice: <Gamepad2 className="w-5 h-5" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
const EVENT_COLORS: Record<EventType, { selected: string; icon: string }> = {
|
||||||
|
golden_hour: { selected: 'border-yellow-500/50 bg-yellow-500/10', icon: 'text-yellow-400' },
|
||||||
|
common_enemy: { selected: 'border-red-500/50 bg-red-500/10', icon: 'text-red-400' },
|
||||||
|
double_risk: { selected: 'border-purple-500/50 bg-purple-500/10', icon: 'text-purple-400' },
|
||||||
|
jackpot: { selected: 'border-green-500/50 bg-green-500/10', icon: 'text-green-400' },
|
||||||
|
swap: { selected: 'border-neon-500/50 bg-neon-500/10', icon: 'text-neon-400' },
|
||||||
|
game_choice: { selected: 'border-orange-500/50 bg-orange-500/10', icon: 'text-orange-400' },
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default durations for events (in minutes)
|
// Default durations for events (in minutes)
|
||||||
@@ -107,54 +116,81 @@ export function EventControl({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (activeEvent.event) {
|
if (activeEvent.event) {
|
||||||
|
const colors = EVENT_COLORS[activeEvent.event.type]
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-gray-800 rounded-xl">
|
<div className={`glass rounded-xl p-4 border ${colors.selected}`}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg bg-white/10 ${colors.icon}`}>
|
||||||
{EVENT_ICONS[activeEvent.event.type]}
|
{EVENT_ICONS[activeEvent.event.type]}
|
||||||
<span className="font-medium">
|
|
||||||
Активно: {EVENT_INFO[activeEvent.event.type].name}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div>
|
||||||
variant="danger"
|
<span className="font-semibold text-white">
|
||||||
|
{EVENT_INFO[activeEvent.event.type].name}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400 text-sm ml-2">активно</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NeonButton
|
||||||
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleStop}
|
onClick={handleStop}
|
||||||
isLoading={isStopping}
|
isLoading={isStopping}
|
||||||
|
className="border-red-500/50 text-red-400 hover:bg-red-500/10"
|
||||||
|
icon={<Square className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
<Square className="w-4 h-4 mr-1" />
|
|
||||||
Остановить
|
Остановить
|
||||||
</Button>
|
</NeonButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-gray-800 rounded-xl space-y-4">
|
<div className="glass rounded-xl p-5 space-y-5">
|
||||||
<h3 className="font-bold text-white">Запустить событие</h3>
|
<div className="flex items-center gap-3">
|
||||||
|
<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-white">Запустить событие</h3>
|
||||||
|
<p className="text-sm text-gray-400">Выберите тип и настройте параметры</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
{EVENT_TYPES.map((type) => (
|
{EVENT_TYPES.map((type) => {
|
||||||
|
const colors = EVENT_COLORS[type]
|
||||||
|
const isSelected = selectedType === type
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={type}
|
key={type}
|
||||||
onClick={() => handleTypeChange(type)}
|
onClick={() => handleTypeChange(type)}
|
||||||
className={`
|
className={`
|
||||||
p-3 rounded-lg border-2 transition-all text-left
|
relative p-4 rounded-xl border-2 transition-all duration-300 text-left
|
||||||
${selectedType === type
|
${isSelected
|
||||||
? 'border-primary-500 bg-primary-500/10'
|
? `${colors.selected} shadow-lg`
|
||||||
: 'border-gray-700 hover:border-gray-600'}
|
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
|
||||||
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className={`flex items-center gap-2 mb-2 ${isSelected ? colors.icon : 'text-gray-400'}`}>
|
||||||
{EVENT_ICONS[type]}
|
{EVENT_ICONS[type]}
|
||||||
<span className="font-medium text-sm">{EVENT_INFO[type].name}</span>
|
<span className={`font-semibold text-sm ${isSelected ? 'text-white' : 'text-gray-300'}`}>
|
||||||
|
{EVENT_INFO[type].name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400 line-clamp-2">
|
<p className="text-xs text-gray-500 line-clamp-2">
|
||||||
{EVENT_INFO[type].description}
|
{EVENT_INFO[type].description}
|
||||||
</p>
|
</p>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="absolute top-2 right-2">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${colors.icon.replace('text-', 'bg-')} animate-pulse`} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Duration setting */}
|
{/* Duration setting */}
|
||||||
@@ -170,9 +206,9 @@ export function EventControl({
|
|||||||
min={1}
|
min={1}
|
||||||
max={480}
|
max={480}
|
||||||
placeholder={`По умолчанию: ${DEFAULT_DURATIONS[selectedType]}`}
|
placeholder={`По умолчанию: ${DEFAULT_DURATIONS[selectedType]}`}
|
||||||
className="w-full p-2 bg-gray-700 border border-gray-600 rounded-lg text-white"
|
className="input w-full"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1.5">
|
||||||
Оставьте пустым для значения по умолчанию ({DEFAULT_DURATIONS[selectedType]} мин)
|
Оставьте пустым для значения по умолчанию ({DEFAULT_DURATIONS[selectedType]} мин)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,7 +222,7 @@ export function EventControl({
|
|||||||
<select
|
<select
|
||||||
value={selectedChallengeId || ''}
|
value={selectedChallengeId || ''}
|
||||||
onChange={(e) => setSelectedChallengeId(e.target.value ? Number(e.target.value) : null)}
|
onChange={(e) => setSelectedChallengeId(e.target.value ? Number(e.target.value) : null)}
|
||||||
className="w-full p-2 bg-gray-700 border border-gray-600 rounded-lg text-white"
|
className="input w-full"
|
||||||
>
|
>
|
||||||
<option value="">— Выберите челлендж —</option>
|
<option value="">— Выберите челлендж —</option>
|
||||||
{challenges.map((c) => (
|
{challenges.map((c) => (
|
||||||
@@ -198,15 +234,15 @@ export function EventControl({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<NeonButton
|
||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
isLoading={isStarting}
|
isLoading={isStarting}
|
||||||
disabled={selectedType === 'common_enemy' && !selectedChallengeId}
|
disabled={selectedType === 'common_enemy' && !selectedChallengeId}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
icon={<Play className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
<Play className="w-4 h-4 mr-2" />
|
|
||||||
Запустить {EVENT_INFO[selectedType].name}
|
Запустить {EVENT_INFO[selectedType].name}
|
||||||
</Button>
|
</NeonButton>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useCallback, useMemo } from 'react'
|
||||||
import type { Game } from '@/types'
|
import type { Game } from '@/types'
|
||||||
|
import { Gamepad2, Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
interface SpinWheelProps {
|
interface SpinWheelProps {
|
||||||
games: Game[]
|
games: Game[]
|
||||||
@@ -8,33 +9,80 @@ interface SpinWheelProps {
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const ITEM_HEIGHT = 100
|
const SPIN_DURATION = 5000 // ms
|
||||||
const VISIBLE_ITEMS = 5
|
const EXTRA_ROTATIONS = 5
|
||||||
const SPIN_DURATION = 4000
|
|
||||||
const EXTRA_ROTATIONS = 3
|
// Цветовая палитра секторов
|
||||||
|
const SECTOR_COLORS = [
|
||||||
|
{ bg: '#0d9488', border: '#14b8a6' }, // teal
|
||||||
|
{ bg: '#7c3aed', border: '#8b5cf6' }, // violet
|
||||||
|
{ bg: '#0891b2', border: '#06b6d4' }, // cyan
|
||||||
|
{ bg: '#c026d3', border: '#d946ef' }, // fuchsia
|
||||||
|
{ bg: '#059669', border: '#10b981' }, // emerald
|
||||||
|
{ bg: '#7c2d12', border: '#ea580c' }, // orange
|
||||||
|
{ bg: '#1d4ed8', border: '#3b82f6' }, // blue
|
||||||
|
{ bg: '#be123c', border: '#e11d48' }, // rose
|
||||||
|
]
|
||||||
|
|
||||||
export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheelProps) {
|
export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheelProps) {
|
||||||
const [isSpinning, setIsSpinning] = useState(false)
|
const [isSpinning, setIsSpinning] = useState(false)
|
||||||
const [offset, setOffset] = useState(0)
|
const [rotation, setRotation] = useState(0)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const animationRef = useRef<number | null>(null)
|
|
||||||
|
|
||||||
// Create extended list for seamless looping
|
// Размеры колеса
|
||||||
const extendedGames = [...games, ...games, ...games, ...games, ...games]
|
const wheelSize = 400
|
||||||
|
const centerX = wheelSize / 2
|
||||||
|
const centerY = wheelSize / 2
|
||||||
|
const radius = wheelSize / 2 - 10
|
||||||
|
|
||||||
|
// Рассчитываем углы секторов
|
||||||
|
const sectorAngle = games.length > 0 ? 360 / games.length : 360
|
||||||
|
|
||||||
|
// Создаём path для сектора
|
||||||
|
const createSectorPath = useCallback((index: number, total: number) => {
|
||||||
|
const angle = 360 / total
|
||||||
|
const startAngle = index * angle - 90 // Начинаем сверху
|
||||||
|
const endAngle = startAngle + angle
|
||||||
|
|
||||||
|
const startRad = (startAngle * Math.PI) / 180
|
||||||
|
const endRad = (endAngle * Math.PI) / 180
|
||||||
|
|
||||||
|
const x1 = centerX + radius * Math.cos(startRad)
|
||||||
|
const y1 = centerY + radius * Math.sin(startRad)
|
||||||
|
const x2 = centerX + radius * Math.cos(endRad)
|
||||||
|
const y2 = centerY + radius * Math.sin(endRad)
|
||||||
|
|
||||||
|
const largeArcFlag = angle > 180 ? 1 : 0
|
||||||
|
|
||||||
|
return `M ${centerX} ${centerY} L ${x1} ${y1} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${x2} ${y2} Z`
|
||||||
|
}, [centerX, centerY, radius])
|
||||||
|
|
||||||
|
// Позиция текста в секторе
|
||||||
|
const getTextPosition = useCallback((index: number, total: number) => {
|
||||||
|
const angle = 360 / total
|
||||||
|
const midAngle = index * angle + angle / 2 - 90
|
||||||
|
const midRad = (midAngle * Math.PI) / 180
|
||||||
|
const textRadius = radius * 0.65
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: centerX + textRadius * Math.cos(midRad),
|
||||||
|
y: centerY + textRadius * Math.sin(midRad),
|
||||||
|
rotation: midAngle + 90, // Текст читается от центра к краю
|
||||||
|
}
|
||||||
|
}, [centerX, centerY, radius])
|
||||||
|
|
||||||
const handleSpin = useCallback(async () => {
|
const handleSpin = useCallback(async () => {
|
||||||
if (isSpinning || disabled || games.length === 0) return
|
if (isSpinning || disabled || games.length === 0) return
|
||||||
|
|
||||||
setIsSpinning(true)
|
setIsSpinning(true)
|
||||||
|
|
||||||
// Get result from API first
|
// Получаем результат от API
|
||||||
const resultGame = await onSpin()
|
const resultGame = await onSpin()
|
||||||
if (!resultGame) {
|
if (!resultGame) {
|
||||||
setIsSpinning(false)
|
setIsSpinning(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find target index
|
// Находим индекс выигравшей игры
|
||||||
const targetIndex = games.findIndex(g => g.id === resultGame.id)
|
const targetIndex = games.findIndex(g => g.id === resultGame.id)
|
||||||
if (targetIndex === -1) {
|
if (targetIndex === -1) {
|
||||||
setIsSpinning(false)
|
setIsSpinning(false)
|
||||||
@@ -42,168 +90,245 @@ export function SpinWheel({ games, onSpin, onSpinComplete, disabled }: SpinWheel
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate animation
|
// Рассчитываем угол для остановки
|
||||||
const totalItems = games.length
|
// Указатель находится сверху (на 0°/360°)
|
||||||
const fullRotations = EXTRA_ROTATIONS * totalItems
|
// Нам нужно чтобы нужный сектор оказался под указателем
|
||||||
const finalPosition = (fullRotations + targetIndex) * ITEM_HEIGHT
|
const targetSectorMidAngle = targetIndex * sectorAngle + sectorAngle / 2
|
||||||
|
|
||||||
// Animate
|
// Полные обороты + угол до центра сектора
|
||||||
const startTime = Date.now()
|
// Колесо крутится по часовой стрелке, указатель сверху
|
||||||
const startOffset = offset % (totalItems * ITEM_HEIGHT)
|
// Чтобы сектор оказался сверху, нужно повернуть на (360 - targetSectorMidAngle)
|
||||||
|
const baseRotation = rotation % 360
|
||||||
|
const fullRotations = EXTRA_ROTATIONS * 360
|
||||||
|
const finalAngle = fullRotations + (360 - targetSectorMidAngle) + baseRotation
|
||||||
|
|
||||||
const animate = () => {
|
setRotation(rotation + finalAngle)
|
||||||
const elapsed = Date.now() - startTime
|
|
||||||
const progress = Math.min(elapsed / SPIN_DURATION, 1)
|
|
||||||
|
|
||||||
// Easing function - starts fast, slows down at end
|
// Ждём окончания анимации
|
||||||
const easeOut = 1 - Math.pow(1 - progress, 4)
|
setTimeout(() => {
|
||||||
|
|
||||||
const currentOffset = startOffset + (finalPosition - startOffset) * easeOut
|
|
||||||
setOffset(currentOffset)
|
|
||||||
|
|
||||||
if (progress < 1) {
|
|
||||||
animationRef.current = requestAnimationFrame(animate)
|
|
||||||
} else {
|
|
||||||
setIsSpinning(false)
|
setIsSpinning(false)
|
||||||
onSpinComplete(resultGame)
|
onSpinComplete(resultGame)
|
||||||
}
|
}, SPIN_DURATION)
|
||||||
|
}, [isSpinning, disabled, games, rotation, sectorAngle, onSpin, onSpinComplete])
|
||||||
|
|
||||||
|
// Сокращаем название игры для отображения
|
||||||
|
const truncateText = (text: string, maxLength: number) => {
|
||||||
|
if (text.length <= maxLength) return text
|
||||||
|
return text.slice(0, maxLength - 2) + '...'
|
||||||
}
|
}
|
||||||
|
|
||||||
animationRef.current = requestAnimationFrame(animate)
|
// Мемоизируем секторы для производительности
|
||||||
}, [isSpinning, disabled, games, offset, onSpin, onSpinComplete])
|
const sectors = useMemo(() => {
|
||||||
|
return games.map((game, index) => {
|
||||||
|
const color = SECTOR_COLORS[index % SECTOR_COLORS.length]
|
||||||
|
const path = createSectorPath(index, games.length)
|
||||||
|
const textPos = getTextPosition(index, games.length)
|
||||||
|
const maxTextLength = games.length > 8 ? 10 : games.length > 5 ? 14 : 18
|
||||||
|
|
||||||
useEffect(() => {
|
return { game, color, path, textPos, maxTextLength }
|
||||||
return () => {
|
})
|
||||||
if (animationRef.current) {
|
}, [games, createSectorPath, getTextPosition])
|
||||||
cancelAnimationFrame(animationRef.current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (games.length === 0) {
|
if (games.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12 text-gray-400">
|
<div className="glass rounded-2xl p-12 text-center">
|
||||||
Нет доступных игр для прокрутки
|
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-700 flex items-center justify-center">
|
||||||
|
<Gamepad2 className="w-8 h-8 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400">Нет доступных игр для прокрутки</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerHeight = VISIBLE_ITEMS * ITEM_HEIGHT
|
|
||||||
const currentIndex = Math.round(offset / ITEM_HEIGHT) % games.length
|
|
||||||
|
|
||||||
// Calculate opacity based on distance from center
|
|
||||||
const getItemOpacity = (itemIndex: number) => {
|
|
||||||
const itemPosition = itemIndex * ITEM_HEIGHT - offset
|
|
||||||
const centerPosition = containerHeight / 2 - ITEM_HEIGHT / 2
|
|
||||||
const distanceFromCenter = Math.abs(itemPosition - centerPosition)
|
|
||||||
const maxDistance = containerHeight / 2
|
|
||||||
const opacity = Math.max(0, 1 - (distanceFromCenter / maxDistance) * 0.8)
|
|
||||||
return opacity
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-6">
|
<div className="flex flex-col items-center gap-6">
|
||||||
{/* Wheel container */}
|
{/* Контейнер колеса */}
|
||||||
<div className="relative w-full max-w-md">
|
<div className="relative">
|
||||||
{/* Selection indicator */}
|
{/* Внешнее свечение */}
|
||||||
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-[100px] border-2 border-primary-500 rounded-lg bg-primary-500/10 z-20 pointer-events-none">
|
<div className={`
|
||||||
<div className="absolute -left-3 top-1/2 -translate-y-1/2 w-0 h-0 border-t-8 border-b-8 border-r-8 border-t-transparent border-b-transparent border-r-primary-500" />
|
absolute -inset-4 rounded-full transition-all duration-500
|
||||||
<div className="absolute -right-3 top-1/2 -translate-y-1/2 w-0 h-0 border-t-8 border-b-8 border-l-8 border-t-transparent border-b-transparent border-l-primary-500" />
|
${isSpinning
|
||||||
|
? 'bg-neon-500/30 blur-2xl animate-pulse'
|
||||||
|
: 'bg-neon-500/10 blur-xl'
|
||||||
|
}
|
||||||
|
`} />
|
||||||
|
|
||||||
|
{/* Указатель (стрелка сверху) */}
|
||||||
|
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-2 z-20">
|
||||||
|
<div className={`
|
||||||
|
relative transition-all duration-300
|
||||||
|
${isSpinning ? 'scale-110' : ''}
|
||||||
|
`}>
|
||||||
|
{/* Свечение указателя */}
|
||||||
|
<div className={`
|
||||||
|
absolute inset-0 blur-sm transition-opacity duration-300
|
||||||
|
${isSpinning ? 'opacity-100' : 'opacity-50'}
|
||||||
|
`}>
|
||||||
|
<svg width="40" height="50" viewBox="0 0 40 50">
|
||||||
|
<path
|
||||||
|
d="M20 50 L5 15 L20 0 L35 15 Z"
|
||||||
|
fill="#22d3ee"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/* Указатель */}
|
||||||
|
<svg width="40" height="50" viewBox="0 0 40 50" className="relative">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="pointerGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#22d3ee" />
|
||||||
|
<stop offset="100%" stopColor="#0891b2" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path
|
||||||
|
d="M20 50 L5 15 L20 0 L35 15 Z"
|
||||||
|
fill="url(#pointerGradient)"
|
||||||
|
stroke="#67e8f9"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Items container */}
|
{/* Колесо */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
className="relative"
|
||||||
className="relative overflow-hidden"
|
style={{ width: wheelSize, height: wheelSize }}
|
||||||
style={{ height: containerHeight }}
|
|
||||||
>
|
>
|
||||||
<div
|
{/* Внешний ободок */}
|
||||||
className="absolute w-full transition-none"
|
<div className={`
|
||||||
|
absolute inset-0 rounded-full
|
||||||
|
border-4 transition-all duration-300
|
||||||
|
${isSpinning
|
||||||
|
? 'border-neon-400 shadow-[0_0_30px_rgba(34,211,238,0.5),inset_0_0_30px_rgba(34,211,238,0.1)]'
|
||||||
|
: 'border-neon-500/50 shadow-[0_0_15px_rgba(34,211,238,0.2)]'
|
||||||
|
}
|
||||||
|
`} />
|
||||||
|
|
||||||
|
{/* SVG колесо */}
|
||||||
|
<svg
|
||||||
|
width={wheelSize}
|
||||||
|
height={wheelSize}
|
||||||
|
className="relative z-10 transition-transform"
|
||||||
style={{
|
style={{
|
||||||
transform: `translateY(${containerHeight / 2 - ITEM_HEIGHT / 2 - offset}px)`,
|
transform: `rotate(${rotation}deg)`,
|
||||||
|
transitionProperty: isSpinning ? 'transform' : 'none',
|
||||||
|
transitionDuration: isSpinning ? `${SPIN_DURATION}ms` : '0ms',
|
||||||
|
transitionTimingFunction: 'cubic-bezier(0.17, 0.67, 0.12, 0.99)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{extendedGames.map((game, index) => {
|
<defs>
|
||||||
const realIndex = index % games.length
|
{/* Тени для секторов */}
|
||||||
const isSelected = !isSpinning && realIndex === currentIndex
|
<filter id="sectorShadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
const opacity = getItemOpacity(index)
|
<feDropShadow dx="0" dy="0" stdDeviation="2" floodColor="#000" floodOpacity="0.3" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
return (
|
{/* Секторы */}
|
||||||
<div
|
{sectors.map(({ game, color, path, textPos, maxTextLength }, index) => (
|
||||||
key={`${game.id}-${index}`}
|
<g key={game.id}>
|
||||||
className={`flex items-center gap-4 px-4 transition-transform duration-200 ${
|
{/* Сектор */}
|
||||||
isSelected ? 'scale-105' : ''
|
<path
|
||||||
}`}
|
d={path}
|
||||||
style={{ height: ITEM_HEIGHT, opacity }}
|
fill={color.bg}
|
||||||
>
|
stroke={color.border}
|
||||||
{/* Game cover */}
|
strokeWidth="2"
|
||||||
<div className="w-16 h-16 rounded-lg overflow-hidden bg-gray-700 flex-shrink-0">
|
filter="url(#sectorShadow)"
|
||||||
{game.cover_url ? (
|
|
||||||
<img
|
|
||||||
src={game.cover_url}
|
|
||||||
alt={game.title}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<div className="w-full h-full flex items-center justify-center text-2xl">
|
|
||||||
🎮
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Game info */}
|
{/* Текст названия игры */}
|
||||||
<div className="flex-1 min-w-0">
|
<text
|
||||||
<h3 className="font-bold text-white truncate text-lg">
|
x={textPos.x}
|
||||||
{game.title}
|
y={textPos.y}
|
||||||
</h3>
|
transform={`rotate(${textPos.rotation}, ${textPos.x}, ${textPos.y})`}
|
||||||
{game.genre && (
|
textAnchor="middle"
|
||||||
<p className="text-sm text-gray-400 truncate">{game.genre}</p>
|
dominantBaseline="middle"
|
||||||
)}
|
fill="white"
|
||||||
</div>
|
fontSize={games.length > 10 ? "10" : games.length > 6 ? "11" : "13"}
|
||||||
</div>
|
fontWeight="bold"
|
||||||
)
|
style={{
|
||||||
})}
|
textShadow: '0 1px 3px rgba(0,0,0,0.8)',
|
||||||
</div>
|
pointerEvents: 'none',
|
||||||
</div>
|
}}
|
||||||
</div>
|
>
|
||||||
|
{truncateText(game.title, maxTextLength)}
|
||||||
|
</text>
|
||||||
|
|
||||||
{/* Spin button */}
|
{/* Разделительная линия */}
|
||||||
|
<line
|
||||||
|
x1={centerX}
|
||||||
|
y1={centerY}
|
||||||
|
x2={centerX + radius * Math.cos((index * sectorAngle - 90) * Math.PI / 180)}
|
||||||
|
y2={centerY + radius * Math.sin((index * sectorAngle - 90) * Math.PI / 180)}
|
||||||
|
stroke="rgba(255,255,255,0.3)"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Центральный круг */}
|
||||||
|
<circle
|
||||||
|
cx={centerX}
|
||||||
|
cy={centerY}
|
||||||
|
r="50"
|
||||||
|
fill="url(#centerGradient)"
|
||||||
|
stroke="#22d3ee"
|
||||||
|
strokeWidth="3"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="centerGradient" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stopColor="#1e293b" />
|
||||||
|
<stop offset="100%" stopColor="#0f172a" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Кнопка КРУТИТЬ в центре */}
|
||||||
<button
|
<button
|
||||||
onClick={handleSpin}
|
onClick={handleSpin}
|
||||||
disabled={isSpinning || disabled}
|
disabled={isSpinning || disabled}
|
||||||
className={`
|
className={`
|
||||||
relative px-12 py-4 text-xl font-bold rounded-full
|
absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
|
||||||
transition-all duration-300 transform
|
w-24 h-24 rounded-full z-20
|
||||||
${isSpinning || disabled
|
flex flex-col items-center justify-center gap-1
|
||||||
? 'bg-gray-700 text-gray-400 cursor-not-allowed'
|
font-bold text-sm uppercase tracking-wider
|
||||||
: 'bg-gradient-to-r from-primary-500 to-primary-600 text-white hover:scale-105 hover:shadow-lg hover:shadow-primary-500/30 active:scale-95'
|
transition-all duration-300
|
||||||
|
disabled:cursor-not-allowed
|
||||||
|
${isSpinning
|
||||||
|
? 'bg-dark-800 text-neon-400 shadow-[0_0_20px_rgba(34,211,238,0.4)]'
|
||||||
|
: 'bg-gradient-to-br from-neon-500 to-cyan-600 text-white hover:shadow-[0_0_30px_rgba(34,211,238,0.6)] hover:scale-105 active:scale-95'
|
||||||
}
|
}
|
||||||
|
${disabled && !isSpinning ? 'opacity-50' : ''}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{isSpinning ? (
|
{isSpinning ? (
|
||||||
<span className="flex items-center gap-2">
|
<Loader2 className="w-8 h-8 animate-spin" />
|
||||||
<svg className="animate-spin w-6 h-6" viewBox="0 0 24 24">
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Крутится...
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
'КРУТИТЬ!'
|
<>
|
||||||
|
<span className="text-xs">КРУТИТЬ</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Декоративные элементы при вращении */}
|
||||||
|
{isSpinning && (
|
||||||
|
<>
|
||||||
|
<div className="absolute inset-0 rounded-full border-2 border-neon-400/30 animate-ping" />
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 rounded-full border border-accent-400/20"
|
||||||
|
style={{ animation: 'ping 1s cubic-bezier(0, 0, 0.2, 1) infinite 0.5s' }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Подсказка */}
|
||||||
|
<p className={`
|
||||||
|
text-sm transition-all duration-300
|
||||||
|
${isSpinning ? 'text-neon-400 animate-pulse' : 'text-gray-500'}
|
||||||
|
`}>
|
||||||
|
{isSpinning ? 'Колесо вращается...' : 'Нажмите на колесо, чтобы крутить!'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
100
frontend/src/components/TelegramBotBanner.tsx
Normal file
100
frontend/src/components/TelegramBotBanner.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { contentApi } from '@/api/admin'
|
||||||
|
import { NeonButton } from '@/components/ui'
|
||||||
|
import { Bot, Bell, X } from 'lucide-react'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'telegram_banner_dismissed'
|
||||||
|
|
||||||
|
// Default content if not configured in admin
|
||||||
|
const DEFAULT_TITLE = 'Привяжите Telegram-бота'
|
||||||
|
const DEFAULT_DESCRIPTION = 'Получайте уведомления о событиях марафона, новых заданиях и результатах прямо в Telegram'
|
||||||
|
|
||||||
|
export function TelegramBotBanner() {
|
||||||
|
const user = useAuthStore((state) => state.user)
|
||||||
|
const [dismissed, setDismissed] = useState(() => {
|
||||||
|
return sessionStorage.getItem(STORAGE_KEY) === 'true'
|
||||||
|
})
|
||||||
|
const [title, setTitle] = useState(DEFAULT_TITLE)
|
||||||
|
const [description, setDescription] = useState(DEFAULT_DESCRIPTION)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadContent = async () => {
|
||||||
|
try {
|
||||||
|
const data = await contentApi.getPublicContent('telegram_bot_info')
|
||||||
|
if (data.title) setTitle(data.title)
|
||||||
|
if (data.content) setDescription(data.content)
|
||||||
|
} catch {
|
||||||
|
// Use defaults if content not found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadContent()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDismiss = () => {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, 'true')
|
||||||
|
setDismissed(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't show if user already has Telegram linked or dismissed
|
||||||
|
if (user?.telegram_id || dismissed) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative rounded-2xl overflow-hidden">
|
||||||
|
{/* Background image */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-cover bg-center"
|
||||||
|
style={{ backgroundImage: 'url(/telegram_bot_banner.png)' }}
|
||||||
|
/>
|
||||||
|
{/* Overlay */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-dark-900/95 via-dark-900/80 to-dark-900/60" />
|
||||||
|
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={handleDismiss}
|
||||||
|
className="absolute top-3 right-3 p-1.5 text-white bg-dark-700/70 hover:bg-dark-600 rounded-lg transition-colors z-10"
|
||||||
|
title="Скрыть"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative p-6 pr-12 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-[#2AABEE]/20 border border-[#2AABEE]/30 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Bot className="w-6 h-6 text-[#2AABEE]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-1">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-400 text-sm max-w-md">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Bell className="w-3 h-3" />
|
||||||
|
Мгновенные уведомления
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Bot className="w-3 h-3" />
|
||||||
|
Удобное управление
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-16 sm:ml-0">
|
||||||
|
<Link to="/profile">
|
||||||
|
<NeonButton color="neon" size="sm">
|
||||||
|
Привязать
|
||||||
|
</NeonButton>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -125,8 +125,8 @@ export function TelegramLink() {
|
|||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
className={`p-2 rounded-lg transition-colors ${
|
className={`p-2 rounded-lg transition-colors ${
|
||||||
isLinked
|
isLinked
|
||||||
? 'text-blue-400 hover:text-blue-300 hover:bg-gray-700'
|
? 'text-blue-400 hover:text-blue-300 hover:bg-dark-700'
|
||||||
: 'text-gray-400 hover:text-white hover:bg-gray-700'
|
: 'text-gray-400 hover:text-white hover:bg-dark-700'
|
||||||
}`}
|
}`}
|
||||||
title={isLinked ? 'Telegram привязан' : 'Привязать Telegram'}
|
title={isLinked ? 'Telegram привязан' : 'Привязать Telegram'}
|
||||||
>
|
>
|
||||||
@@ -134,17 +134,17 @@ export function TelegramLink() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-gray-800 rounded-xl max-w-md w-full p-6 relative">
|
<div className="glass rounded-xl max-w-md w-full p-6 relative border border-dark-600">
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="absolute top-4 right-4 text-gray-400 hover:text-white"
|
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<div className="w-12 h-12 bg-blue-500/20 rounded-full flex items-center justify-center">
|
<div className="w-12 h-12 bg-blue-500/10 rounded-full flex items-center justify-center border border-blue-500/30">
|
||||||
<MessageCircle className="w-6 h-6 text-blue-400" />
|
<MessageCircle className="w-6 h-6 text-blue-400" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -171,7 +171,7 @@ export function TelegramLink() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* User Profile Card */}
|
{/* User Profile Card */}
|
||||||
<div className="p-4 bg-gradient-to-br from-gray-700/50 to-gray-800/50 rounded-xl border border-gray-600/50">
|
<div className="p-4 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* Avatar - Telegram avatar */}
|
{/* Avatar - Telegram avatar */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -182,12 +182,12 @@ export function TelegramLink() {
|
|||||||
className="w-12 h-12 rounded-full object-cover border-2 border-blue-500/50"
|
className="w-12 h-12 rounded-full object-cover border-2 border-blue-500/50"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center border-2 border-blue-500/50">
|
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-accent-500 flex items-center justify-center border-2 border-blue-500/50">
|
||||||
<User className="w-6 h-6 text-white" />
|
<User className="w-6 h-6 text-white" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Link indicator */}
|
{/* Link indicator */}
|
||||||
<div className="absolute -bottom-1 -right-1 w-5 h-5 bg-green-500 rounded-full flex items-center justify-center border-2 border-gray-800">
|
<div className="absolute -bottom-1 -right-1 w-5 h-5 bg-green-500 rounded-full flex items-center justify-center border-2 border-dark-800">
|
||||||
<Link2 className="w-2.5 h-2.5 text-white" />
|
<Link2 className="w-2.5 h-2.5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,7 +205,7 @@ export function TelegramLink() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notifications Info */}
|
{/* Notifications Info */}
|
||||||
<div className="p-4 bg-gray-700/30 rounded-lg">
|
<div className="p-4 bg-dark-700/30 rounded-lg border border-dark-600/50">
|
||||||
<p className="text-sm text-gray-300 font-medium mb-3">Уведомления включены:</p>
|
<p className="text-sm text-gray-300 font-medium mb-3">Уведомления включены:</p>
|
||||||
<div className="grid grid-cols-1 gap-2">
|
<div className="grid grid-cols-1 gap-2">
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||||
@@ -254,7 +254,7 @@ export function TelegramLink() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenBot}
|
onClick={handleOpenBot}
|
||||||
className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
|
className="w-full py-3 px-4 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 border border-blue-500/50 hover:border-blue-500 rounded-lg font-medium transition-all duration-200 flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-5 h-5" />
|
<ExternalLink className="w-5 h-5" />
|
||||||
Открыть Telegram снова
|
Открыть Telegram снова
|
||||||
@@ -268,13 +268,13 @@ export function TelegramLink() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenBot}
|
onClick={handleOpenBot}
|
||||||
className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2"
|
className="w-full py-3 px-4 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 border border-blue-500/50 hover:border-blue-500 rounded-lg font-medium transition-all duration-200 flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-5 h-5" />
|
<ExternalLink className="w-5 h-5" />
|
||||||
Открыть Telegram
|
Открыть Telegram
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="text-sm text-gray-500 text-center">
|
<p className="text-sm text-gray-400 text-center">
|
||||||
Ссылка действительна 10 минут
|
Ссылка действительна 10 минут
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
@@ -304,7 +304,7 @@ export function TelegramLink() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleGenerateLink}
|
onClick={handleGenerateLink}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
className="w-full py-3 px-4 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 border border-blue-500/50 hover:border-blue-500 rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
|||||||
@@ -1,42 +1,103 @@
|
|||||||
import { Outlet, Link, useNavigate } from 'react-router-dom'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { Gamepad2, LogOut, Trophy, User } 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'
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const { user, isAuthenticated, logout } = useAuthStore()
|
const { user, isAuthenticated, logout } = useAuthStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const [isScrolled, setIsScrolled] = useState(false)
|
||||||
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
setIsScrolled(window.scrollY > 10)
|
||||||
|
}
|
||||||
|
window.addEventListener('scroll', handleScroll)
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Close mobile menu on route change
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMobileMenuOpen(false)
|
||||||
|
}, [location])
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout()
|
logout()
|
||||||
navigate('/login')
|
navigate('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isActiveLink = (path: string) => location.pathname === path
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="bg-gray-800 border-b border-gray-700">
|
<header
|
||||||
|
className={clsx(
|
||||||
|
'fixed top-0 left-0 right-0 z-50 transition-all duration-300',
|
||||||
|
isScrolled
|
||||||
|
? 'bg-dark-900/80 backdrop-blur-lg border-b border-dark-600/50 shadow-lg'
|
||||||
|
: 'bg-transparent'
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
|
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
|
||||||
<Link to="/" className="flex items-center gap-2 text-xl font-bold text-white">
|
{/* Logo */}
|
||||||
<Gamepad2 className="w-8 h-8 text-primary-500" />
|
<Link
|
||||||
<span>Игровой Марафон</span>
|
to="/"
|
||||||
|
className="flex items-center gap-3 group"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<Gamepad2 className="w-8 h-8 text-neon-500 transition-all duration-300 group-hover:text-neon-400 group-hover:drop-shadow-[0_0_8px_rgba(34,211,238,0.6)]" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold text-white font-display tracking-wider glitch-hover">
|
||||||
|
МАРАФОН
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="flex items-center gap-4">
|
{/* Desktop Navigation */}
|
||||||
|
<nav className="hidden md:flex items-center gap-6">
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
to="/marathons"
|
to="/marathons"
|
||||||
className="flex items-center gap-2 text-gray-300 hover:text-white transition-colors"
|
className={clsx(
|
||||||
|
'flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200',
|
||||||
|
isActiveLink('/marathons')
|
||||||
|
? 'text-neon-400 bg-neon-500/10'
|
||||||
|
: 'text-gray-300 hover:text-white hover:bg-dark-700'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Trophy className="w-5 h-5" />
|
<Trophy className="w-5 h-5" />
|
||||||
<span>Марафоны</span>
|
<span>Марафоны</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 ml-4 pl-4 border-l border-gray-700">
|
{user?.role === 'admin' && (
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200',
|
||||||
|
location.pathname.startsWith('/admin')
|
||||||
|
? 'text-purple-400 bg-purple-500/10'
|
||||||
|
: 'text-gray-300 hover:text-white hover:bg-dark-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Shield className="w-5 h-5" />
|
||||||
|
<span>Админка</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 ml-2 pl-4 border-l border-dark-600">
|
||||||
<Link
|
<Link
|
||||||
to="/profile"
|
to="/profile"
|
||||||
className="flex items-center gap-2 text-gray-300 hover:text-white transition-colors"
|
className={clsx(
|
||||||
|
'flex items-center gap-2 px-3 py-2 rounded-lg transition-all duration-200',
|
||||||
|
isActiveLink('/profile')
|
||||||
|
? 'text-neon-400 bg-neon-500/10'
|
||||||
|
: 'text-gray-300 hover:text-white hover:bg-dark-700'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<User className="w-5 h-5" />
|
<User className="w-5 h-5" />
|
||||||
<span>{user?.nickname}</span>
|
<span>{user?.nickname}</span>
|
||||||
@@ -46,7 +107,7 @@ export function Layout() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="p-2 text-gray-400 hover:text-white transition-colors"
|
className="p-2 text-gray-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-all duration-200"
|
||||||
title="Выйти"
|
title="Выйти"
|
||||||
>
|
>
|
||||||
<LogOut className="w-5 h-5" />
|
<LogOut className="w-5 h-5" />
|
||||||
@@ -55,27 +116,134 @@ export function Layout() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Link to="/login" className="text-gray-300 hover:text-white transition-colors">
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="text-gray-300 hover:text-white transition-colors px-4 py-2"
|
||||||
|
>
|
||||||
Войти
|
Войти
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/register" className="btn btn-primary">
|
<Link
|
||||||
|
to="/register"
|
||||||
|
className="px-4 py-2 bg-neon-500 hover:bg-neon-400 text-dark-900 font-semibold rounded-lg transition-all duration-200 shadow-[0_0_10px_rgba(34,211,238,0.25)] hover:shadow-[0_0_16px_rgba(34,211,238,0.4)]"
|
||||||
|
>
|
||||||
Регистрация
|
Регистрация
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Mobile Menu Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
|
className="md:hidden p-2 text-gray-300 hover:text-white rounded-lg hover:bg-dark-700 transition-colors"
|
||||||
|
>
|
||||||
|
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu */}
|
||||||
|
{isMobileMenuOpen && (
|
||||||
|
<div className="md:hidden bg-dark-800/95 backdrop-blur-lg border-t border-dark-600 animate-slide-in-down">
|
||||||
|
<div className="container mx-auto px-4 py-4 space-y-2">
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to="/marathons"
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
|
||||||
|
isActiveLink('/marathons')
|
||||||
|
? 'text-neon-400 bg-neon-500/10'
|
||||||
|
: 'text-gray-300 hover:text-white hover:bg-dark-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trophy className="w-5 h-5" />
|
||||||
|
<span>Марафоны</span>
|
||||||
|
</Link>
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
|
||||||
|
location.pathname.startsWith('/admin')
|
||||||
|
? 'text-purple-400 bg-purple-500/10'
|
||||||
|
: 'text-gray-300 hover:text-white hover:bg-dark-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Shield className="w-5 h-5" />
|
||||||
|
<span>Админка</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
to="/profile"
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
|
||||||
|
isActiveLink('/profile')
|
||||||
|
? 'text-neon-400 bg-neon-500/10'
|
||||||
|
: 'text-gray-300 hover:text-white hover:bg-dark-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<User className="w-5 h-5" />
|
||||||
|
<span>{user?.nickname}</span>
|
||||||
|
</Link>
|
||||||
|
<div className="pt-2 border-t border-dark-600">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex items-center gap-3 w-full px-4 py-3 text-red-400 hover:bg-red-500/10 rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
<LogOut className="w-5 h-5" />
|
||||||
|
<span>Выйти</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="block px-4 py-3 text-gray-300 hover:text-white hover:bg-dark-700 rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/register"
|
||||||
|
className="block px-4 py-3 text-center bg-neon-500 hover:bg-neon-400 text-dark-900 font-semibold rounded-lg transition-all"
|
||||||
|
>
|
||||||
|
Регистрация
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Spacer for fixed header */}
|
||||||
|
<div className="h-[72px]" />
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<main className="flex-1 container mx-auto px-4 py-8">
|
<main className="flex-1 container mx-auto px-4 py-8">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="bg-gray-800 border-t border-gray-700 py-4">
|
<footer className="bg-dark-800/50 border-t border-dark-600/50 py-6">
|
||||||
<div className="container mx-auto px-4 text-center text-gray-500 text-sm">
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-gray-500">
|
||||||
|
<Gamepad2 className="w-5 h-5 text-neon-500/50" />
|
||||||
|
<span className="text-sm">
|
||||||
Игровой Марафон © {new Date().getFullYear()}
|
Игровой Марафон © {new Date().getFullYear()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6 text-sm">
|
||||||
|
<Link to="/terms" className="text-gray-500 hover:text-gray-300 transition-colors">
|
||||||
|
Правила
|
||||||
|
</Link>
|
||||||
|
<Link to="/privacy" className="text-gray-500 hover:text-gray-300 transition-colors">
|
||||||
|
Конфиденциальность
|
||||||
|
</Link>
|
||||||
|
<span className="text-neon-500/50">v1.0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
disabled={disabled || isLoading}
|
disabled={disabled || isLoading}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'inline-flex items-center justify-center font-medium rounded-lg transition-colors',
|
'inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200',
|
||||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
{
|
{
|
||||||
'bg-primary-600 hover:bg-primary-700 text-white': variant === 'primary',
|
'bg-neon-500 hover:bg-neon-400 text-dark-900 font-semibold shadow-[0_0_8px_rgba(34,211,238,0.25)] hover:shadow-[0_0_14px_rgba(34,211,238,0.4)]': variant === 'primary',
|
||||||
'bg-gray-700 hover:bg-gray-600 text-white': variant === 'secondary',
|
'bg-dark-600 hover:bg-dark-500 text-white border border-dark-500': variant === 'secondary',
|
||||||
'bg-red-600 hover:bg-red-700 text-white': variant === 'danger',
|
'bg-red-600 hover:bg-red-700 text-white': variant === 'danger',
|
||||||
'bg-transparent hover:bg-gray-800 text-gray-300': variant === 'ghost',
|
'bg-transparent hover:bg-dark-700 text-gray-300 hover:text-white': variant === 'ghost',
|
||||||
'px-3 py-1.5 text-sm': size === 'sm',
|
'px-3 py-1.5 text-sm': size === 'sm',
|
||||||
'px-4 py-2 text-base': size === 'md',
|
'px-4 py-2 text-base': size === 'md',
|
||||||
'px-6 py-3 text-lg': size === 'lg',
|
'px-6 py-3 text-lg': size === 'lg',
|
||||||
|
|||||||
@@ -4,11 +4,18 @@ import { clsx } from 'clsx'
|
|||||||
interface CardProps {
|
interface CardProps {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
|
hover?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Card({ children, className }: CardProps) {
|
export function Card({ children, className, hover = false }: CardProps) {
|
||||||
return (
|
return (
|
||||||
<div className={clsx('bg-gray-800 rounded-xl p-6 shadow-lg', className)}>
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'bg-dark-800 rounded-xl p-6 border border-dark-600',
|
||||||
|
hover && 'transition-all duration-300 hover:-translate-y-1 hover:border-neon-500/30 hover:shadow-[0_10px_40px_rgba(34,211,238,0.08)]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect } from 'react'
|
|||||||
import { AlertTriangle, Info, Trash2, X } from 'lucide-react'
|
import { AlertTriangle, Info, Trash2, X } from 'lucide-react'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { useConfirmStore, type ConfirmVariant } from '@/store/confirm'
|
import { useConfirmStore, type ConfirmVariant } from '@/store/confirm'
|
||||||
import { Button } from './Button'
|
import { NeonButton } from './NeonButton'
|
||||||
|
|
||||||
const icons: Record<ConfirmVariant, React.ReactNode> = {
|
const icons: Record<ConfirmVariant, React.ReactNode> = {
|
||||||
danger: <Trash2 className="w-6 h-6" />,
|
danger: <Trash2 className="w-6 h-6" />,
|
||||||
@@ -11,15 +11,15 @@ const icons: Record<ConfirmVariant, React.ReactNode> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const iconStyles: Record<ConfirmVariant, string> = {
|
const iconStyles: Record<ConfirmVariant, string> = {
|
||||||
danger: 'bg-red-500/20 text-red-500',
|
danger: 'bg-red-500/10 text-red-400 border border-red-500/30',
|
||||||
warning: 'bg-yellow-500/20 text-yellow-500',
|
warning: 'bg-yellow-500/10 text-yellow-400 border border-yellow-500/30',
|
||||||
info: 'bg-blue-500/20 text-blue-500',
|
info: 'bg-neon-500/10 text-neon-400 border border-neon-500/30',
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttonVariants: Record<ConfirmVariant, 'danger' | 'primary' | 'secondary'> = {
|
const confirmButtonStyles: Record<ConfirmVariant, string> = {
|
||||||
danger: 'danger',
|
danger: 'border-red-500/50 text-red-400 hover:bg-red-500/10 hover:border-red-500',
|
||||||
warning: 'primary',
|
warning: 'border-yellow-500/50 text-yellow-400 hover:bg-yellow-500/10 hover:border-yellow-500',
|
||||||
info: 'primary',
|
info: '', // Will use NeonButton default
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfirmModal() {
|
export function ConfirmModal() {
|
||||||
@@ -62,7 +62,7 @@ export function ConfirmModal() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
<div className="relative bg-gray-800 rounded-xl shadow-2xl max-w-md w-full animate-in zoom-in-95 fade-in duration-200 border border-gray-700">
|
<div className="relative glass rounded-xl shadow-2xl max-w-md w-full animate-in zoom-in-95 fade-in duration-200 border border-dark-600">
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
<button
|
<button
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
@@ -89,20 +89,31 @@ export function ConfirmModal() {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button
|
<NeonButton
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
>
|
>
|
||||||
{options.cancelText || 'Отмена'}
|
{options.cancelText || 'Отмена'}
|
||||||
</Button>
|
</NeonButton>
|
||||||
<Button
|
{variant === 'info' ? (
|
||||||
variant={buttonVariants[variant]}
|
<NeonButton
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
>
|
>
|
||||||
{options.confirmText || 'Подтвердить'}
|
{options.confirmText || 'Подтвердить'}
|
||||||
</Button>
|
</NeonButton>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
'flex-1 px-4 py-2.5 rounded-lg font-semibold transition-all duration-200 border-2 bg-transparent',
|
||||||
|
confirmButtonStyles[variant]
|
||||||
|
)}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
>
|
||||||
|
{options.confirmText || 'Подтвердить'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
215
frontend/src/components/ui/GlassCard.tsx
Normal file
215
frontend/src/components/ui/GlassCard.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import { type ReactNode, type HTMLAttributes } from 'react'
|
||||||
|
import { clsx } from 'clsx'
|
||||||
|
|
||||||
|
interface GlassCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
children: ReactNode
|
||||||
|
variant?: 'default' | 'dark' | 'neon' | 'gradient'
|
||||||
|
hover?: boolean
|
||||||
|
glow?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlassCard({
|
||||||
|
children,
|
||||||
|
variant = 'default',
|
||||||
|
hover = false,
|
||||||
|
glow = false,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: GlassCardProps) {
|
||||||
|
const variantClasses = {
|
||||||
|
default: 'glass',
|
||||||
|
dark: 'glass-dark',
|
||||||
|
neon: 'glass-neon',
|
||||||
|
gradient: 'gradient-border',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'rounded-xl p-6',
|
||||||
|
variantClasses[variant],
|
||||||
|
hover && 'card-hover cursor-pointer',
|
||||||
|
glow && 'neon-glow-pulse',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats card variant
|
||||||
|
interface StatsCardProps {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
icon?: ReactNode
|
||||||
|
trend?: {
|
||||||
|
value: number
|
||||||
|
isPositive: boolean
|
||||||
|
}
|
||||||
|
color?: 'neon' | 'purple' | 'pink' | 'default'
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon,
|
||||||
|
trend,
|
||||||
|
color = 'default',
|
||||||
|
className,
|
||||||
|
}: StatsCardProps) {
|
||||||
|
const colorClasses = {
|
||||||
|
neon: 'border-neon-500/30 hover:border-neon-500/50',
|
||||||
|
purple: 'border-accent-500/30 hover:border-accent-500/50',
|
||||||
|
pink: 'border-pink-500/30 hover:border-pink-500/50',
|
||||||
|
default: 'border-dark-600 hover:border-dark-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconColorClasses = {
|
||||||
|
neon: 'text-neon-500 bg-neon-500/10',
|
||||||
|
purple: 'text-accent-500 bg-accent-500/10',
|
||||||
|
pink: 'text-pink-500 bg-pink-500/10',
|
||||||
|
default: 'text-gray-400 bg-dark-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueColorClasses = {
|
||||||
|
neon: 'text-neon-400',
|
||||||
|
purple: 'text-accent-400',
|
||||||
|
pink: 'text-pink-400',
|
||||||
|
default: 'text-white',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'glass rounded-xl p-4 border transition-all duration-300',
|
||||||
|
colorClasses[color],
|
||||||
|
'hover:-translate-y-0.5',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm text-gray-400 mb-1">{label}</p>
|
||||||
|
<p className={clsx(
|
||||||
|
'font-bold',
|
||||||
|
typeof value === 'number' ? 'text-2xl' : 'text-lg',
|
||||||
|
valueColorClasses[color]
|
||||||
|
)}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
{trend && (
|
||||||
|
<p
|
||||||
|
className={clsx(
|
||||||
|
'text-xs mt-1',
|
||||||
|
trend.isPositive ? 'text-green-400' : 'text-red-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{trend.isPositive ? '+' : ''}{trend.value}%
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{icon && (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0',
|
||||||
|
iconColorClasses[color]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feature card variant
|
||||||
|
interface FeatureCardProps {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
icon: ReactNode
|
||||||
|
color?: 'neon' | 'purple' | 'pink'
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeatureCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
color = 'neon',
|
||||||
|
className,
|
||||||
|
}: FeatureCardProps) {
|
||||||
|
const colorClasses = {
|
||||||
|
neon: {
|
||||||
|
icon: 'text-neon-500 bg-neon-500/10 group-hover:bg-neon-500/20',
|
||||||
|
border: 'group-hover:border-neon-500/50',
|
||||||
|
glow: 'group-hover:shadow-[0_0_20px_rgba(34,211,238,0.12)]',
|
||||||
|
},
|
||||||
|
purple: {
|
||||||
|
icon: 'text-accent-500 bg-accent-500/10 group-hover:bg-accent-500/20',
|
||||||
|
border: 'group-hover:border-accent-500/50',
|
||||||
|
glow: 'group-hover:shadow-[0_0_20px_rgba(139,92,246,0.12)]',
|
||||||
|
},
|
||||||
|
pink: {
|
||||||
|
icon: 'text-pink-500 bg-pink-500/10 group-hover:bg-pink-500/20',
|
||||||
|
border: 'group-hover:border-pink-500/50',
|
||||||
|
glow: 'group-hover:shadow-[0_0_20px_rgba(244,114,182,0.12)]',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = colorClasses[color]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'group glass rounded-xl p-6 border border-dark-600 transition-all duration-300',
|
||||||
|
'hover:-translate-y-1',
|
||||||
|
colors.border,
|
||||||
|
colors.glow,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'w-14 h-14 rounded-xl flex items-center justify-center mb-4 transition-colors',
|
||||||
|
colors.icon
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">{title}</h3>
|
||||||
|
<p className="text-gray-400 text-sm">{description}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interactive card with animated border
|
||||||
|
interface AnimatedBorderCardProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnimatedBorderCard({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AnimatedBorderCardProps) {
|
||||||
|
return (
|
||||||
|
<div className={clsx('relative group', className)} {...props}>
|
||||||
|
{/* Animated gradient border */}
|
||||||
|
<div
|
||||||
|
className="absolute -inset-0.5 bg-gradient-to-r from-neon-500 via-accent-500 to-pink-500 rounded-xl opacity-30 group-hover:opacity-60 blur transition-opacity duration-300"
|
||||||
|
style={{
|
||||||
|
backgroundSize: '200% 200%',
|
||||||
|
animation: 'gradient-flow 3s linear infinite',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Card content */}
|
||||||
|
<div className="relative glass-dark rounded-xl p-6">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
116
frontend/src/components/ui/GlitchText.tsx
Normal file
116
frontend/src/components/ui/GlitchText.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { type ReactNode, type HTMLAttributes } from 'react'
|
||||||
|
import { clsx } from 'clsx'
|
||||||
|
|
||||||
|
interface GlitchTextProps extends HTMLAttributes<HTMLSpanElement> {
|
||||||
|
children: ReactNode
|
||||||
|
as?: 'span' | 'h1' | 'h2' | 'h3' | 'h4' | 'p'
|
||||||
|
intensity?: 'low' | 'medium' | 'high'
|
||||||
|
color?: 'neon' | 'purple' | 'pink' | 'white'
|
||||||
|
hover?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlitchText({
|
||||||
|
children,
|
||||||
|
as: Component = 'span',
|
||||||
|
intensity = 'medium',
|
||||||
|
color = 'neon',
|
||||||
|
hover = false,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: GlitchTextProps) {
|
||||||
|
const text = typeof children === 'string' ? children : ''
|
||||||
|
|
||||||
|
const colorClasses = {
|
||||||
|
neon: 'text-neon-500',
|
||||||
|
purple: 'text-accent-500',
|
||||||
|
pink: 'text-pink-500',
|
||||||
|
white: 'text-white',
|
||||||
|
}
|
||||||
|
|
||||||
|
const glowClasses = {
|
||||||
|
neon: 'neon-text',
|
||||||
|
purple: 'neon-text-purple',
|
||||||
|
pink: '[text-shadow:0_0_8px_rgba(244,114,182,0.5),0_0_16px_rgba(244,114,182,0.25)]',
|
||||||
|
white: '[text-shadow:0_0_8px_rgba(255,255,255,0.4),0_0_16px_rgba(255,255,255,0.2)]',
|
||||||
|
}
|
||||||
|
|
||||||
|
const intensityClasses = {
|
||||||
|
low: 'animate-[glitch-skew_2s_infinite_linear_alternate-reverse]',
|
||||||
|
medium: '',
|
||||||
|
high: 'animate-glitch',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hover) {
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
className={clsx(
|
||||||
|
colorClasses[color],
|
||||||
|
'relative inline-block cursor-pointer transition-all duration-300',
|
||||||
|
'hover:' + glowClasses[color],
|
||||||
|
'glitch-hover',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Component>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
className={clsx(
|
||||||
|
'glitch relative inline-block',
|
||||||
|
colorClasses[color],
|
||||||
|
glowClasses[color],
|
||||||
|
intensityClasses[intensity],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-text={text}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Component>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simpler glitch effect for headings
|
||||||
|
interface GlitchHeadingProps {
|
||||||
|
children: ReactNode
|
||||||
|
level?: 1 | 2 | 3 | 4
|
||||||
|
className?: string
|
||||||
|
gradient?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlitchHeading({
|
||||||
|
children,
|
||||||
|
level = 1,
|
||||||
|
className,
|
||||||
|
gradient = false,
|
||||||
|
}: GlitchHeadingProps) {
|
||||||
|
const text = typeof children === 'string' ? children : ''
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
1: 'text-4xl md:text-5xl lg:text-6xl font-bold',
|
||||||
|
2: 'text-3xl md:text-4xl font-bold',
|
||||||
|
3: 'text-2xl md:text-3xl font-semibold',
|
||||||
|
4: 'text-xl md:text-2xl font-semibold',
|
||||||
|
}
|
||||||
|
|
||||||
|
const Component = `h${level}` as keyof JSX.IntrinsicElements
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
className={clsx(
|
||||||
|
'glitch relative inline-block',
|
||||||
|
sizeClasses[level],
|
||||||
|
gradient ? 'gradient-neon-text' : 'text-white neon-text',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
data-text={text}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Component>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{label && (
|
{label && (
|
||||||
<label htmlFor={id} className="block text-sm font-medium text-gray-300 mb-1">
|
<label htmlFor={id} className="block text-sm font-medium text-gray-300 mb-1.5">
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
@@ -19,15 +19,16 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
id={id}
|
id={id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'w-full px-4 py-2 bg-gray-800 border rounded-lg text-white placeholder-gray-500',
|
'w-full px-4 py-3 bg-dark-800 border rounded-lg text-white placeholder-gray-500',
|
||||||
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent',
|
'focus:outline-none focus:border-neon-500',
|
||||||
'transition-colors',
|
'focus:shadow-[0_0_0_3px_rgba(34,211,238,0.1),0_0_8px_rgba(34,211,238,0.15)]',
|
||||||
error ? 'border-red-500' : 'border-gray-700',
|
'transition-all duration-200',
|
||||||
|
error ? 'border-red-500' : 'border-dark-600',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
|
{error && <p className="mt-1.5 text-sm text-red-400">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
174
frontend/src/components/ui/NeonButton.tsx
Normal file
174
frontend/src/components/ui/NeonButton.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'
|
||||||
|
import { clsx } from 'clsx'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
interface NeonButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
color?: 'neon' | 'purple' | 'pink'
|
||||||
|
isLoading?: boolean
|
||||||
|
icon?: ReactNode
|
||||||
|
iconPosition?: 'left' | 'right'
|
||||||
|
glow?: boolean
|
||||||
|
pulse?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NeonButton = forwardRef<HTMLButtonElement, NeonButtonProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
color = 'neon',
|
||||||
|
isLoading,
|
||||||
|
icon,
|
||||||
|
iconPosition = 'left',
|
||||||
|
glow = true,
|
||||||
|
pulse = false,
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const colorMap = {
|
||||||
|
neon: {
|
||||||
|
primary: 'bg-neon-500 hover:bg-neon-400 text-dark-900',
|
||||||
|
secondary: 'bg-dark-600 hover:bg-dark-500 text-neon-400 border border-neon-500/30',
|
||||||
|
outline: 'bg-transparent border-2 border-neon-500 text-neon-500 hover:bg-neon-500 hover:text-dark-900',
|
||||||
|
ghost: 'bg-transparent hover:bg-neon-500/10 text-neon-400',
|
||||||
|
danger: 'bg-red-600 hover:bg-red-700 text-white',
|
||||||
|
glow: '0 0 12px rgba(34, 211, 238, 0.4)',
|
||||||
|
glowHover: '0 0 18px rgba(34, 211, 238, 0.55)',
|
||||||
|
glowDanger: '0 0 12px rgba(239, 68, 68, 0.4)',
|
||||||
|
glowDangerHover: '0 0 18px rgba(239, 68, 68, 0.55)',
|
||||||
|
},
|
||||||
|
purple: {
|
||||||
|
primary: 'bg-accent-500 hover:bg-accent-400 text-white',
|
||||||
|
secondary: 'bg-dark-600 hover:bg-dark-500 text-accent-400 border border-accent-500/30',
|
||||||
|
outline: 'bg-transparent border-2 border-accent-500 text-accent-500 hover:bg-accent-500 hover:text-white',
|
||||||
|
ghost: 'bg-transparent hover:bg-accent-500/10 text-accent-400',
|
||||||
|
danger: 'bg-red-600 hover:bg-red-700 text-white',
|
||||||
|
glow: '0 0 12px rgba(139, 92, 246, 0.4)',
|
||||||
|
glowHover: '0 0 18px rgba(139, 92, 246, 0.55)',
|
||||||
|
glowDanger: '0 0 12px rgba(239, 68, 68, 0.4)',
|
||||||
|
glowDangerHover: '0 0 18px rgba(239, 68, 68, 0.55)',
|
||||||
|
},
|
||||||
|
pink: {
|
||||||
|
primary: 'bg-pink-500 hover:bg-pink-400 text-white',
|
||||||
|
secondary: 'bg-dark-600 hover:bg-dark-500 text-pink-400 border border-pink-500/30',
|
||||||
|
outline: 'bg-transparent border-2 border-pink-500 text-pink-500 hover:bg-pink-500 hover:text-white',
|
||||||
|
ghost: 'bg-transparent hover:bg-pink-500/10 text-pink-400',
|
||||||
|
danger: 'bg-red-600 hover:bg-red-700 text-white',
|
||||||
|
glow: '0 0 12px rgba(244, 114, 182, 0.4)',
|
||||||
|
glowHover: '0 0 18px rgba(244, 114, 182, 0.55)',
|
||||||
|
glowDanger: '0 0 12px rgba(239, 68, 68, 0.4)',
|
||||||
|
glowDangerHover: '0 0 18px rgba(239, 68, 68, 0.55)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'px-3 py-1.5 text-sm gap-1.5',
|
||||||
|
md: 'px-4 py-2.5 text-base gap-2',
|
||||||
|
lg: 'px-6 py-3 text-lg gap-2.5',
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconSizes = {
|
||||||
|
sm: 'w-4 h-4',
|
||||||
|
md: 'w-5 h-5',
|
||||||
|
lg: 'w-6 h-6',
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = colorMap[color]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
className={clsx(
|
||||||
|
'inline-flex items-center justify-center font-semibold rounded-lg',
|
||||||
|
'transition-all duration-300 ease-out',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-dark-900',
|
||||||
|
color === 'neon' && 'focus:ring-neon-500',
|
||||||
|
color === 'purple' && 'focus:ring-accent-500',
|
||||||
|
color === 'pink' && 'focus:ring-pink-500',
|
||||||
|
colors[variant],
|
||||||
|
sizeClasses[size],
|
||||||
|
pulse && 'neon-glow-pulse',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
boxShadow: glow && !disabled && variant !== 'ghost'
|
||||||
|
? (variant === 'danger' ? colors.glowDanger : colors.glow)
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (glow && !disabled && variant !== 'ghost') {
|
||||||
|
e.currentTarget.style.boxShadow = variant === 'danger' ? colors.glowDangerHover : colors.glowHover
|
||||||
|
}
|
||||||
|
props.onMouseEnter?.(e)
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (glow && !disabled && variant !== 'ghost') {
|
||||||
|
e.currentTarget.style.boxShadow = variant === 'danger' ? colors.glowDanger : colors.glow
|
||||||
|
}
|
||||||
|
props.onMouseLeave?.(e)
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading && <Loader2 className={clsx(iconSizes[size], 'animate-spin')} />}
|
||||||
|
{!isLoading && icon && iconPosition === 'left' && (
|
||||||
|
<span className={iconSizes[size]}>{icon}</span>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
{!isLoading && icon && iconPosition === 'right' && (
|
||||||
|
<span className={iconSizes[size]}>{icon}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
NeonButton.displayName = 'NeonButton'
|
||||||
|
|
||||||
|
// Gradient button variant
|
||||||
|
interface GradientButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
isLoading?: boolean
|
||||||
|
icon?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GradientButton = forwardRef<HTMLButtonElement, GradientButtonProps>(
|
||||||
|
({ className, size = 'md', isLoading, icon, children, disabled, ...props }, ref) => {
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'px-3 py-1.5 text-sm gap-1.5',
|
||||||
|
md: 'px-4 py-2.5 text-base gap-2',
|
||||||
|
lg: 'px-6 py-3 text-lg gap-2.5',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
className={clsx(
|
||||||
|
'relative inline-flex items-center justify-center font-semibold rounded-lg',
|
||||||
|
'bg-gradient-to-r from-neon-500 via-accent-500 to-pink-500',
|
||||||
|
'text-white transition-all duration-300',
|
||||||
|
'hover:shadow-[0_0_20px_rgba(139,92,246,0.35)]',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-accent-500 focus:ring-offset-2 focus:ring-offset-dark-900',
|
||||||
|
sizeClasses[size],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading && <Loader2 className="w-5 h-5 animate-spin" />}
|
||||||
|
{!isLoading && icon && <span className="w-5 h-5">{icon}</span>}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
GradientButton.displayName = 'GradientButton'
|
||||||
@@ -3,6 +3,8 @@ import { usersApi } from '@/api'
|
|||||||
|
|
||||||
// Глобальный кэш для blob URL аватарок
|
// Глобальный кэш для blob URL аватарок
|
||||||
const avatarCache = new Map<number, string>()
|
const avatarCache = new Map<number, string>()
|
||||||
|
// Пользователи, для которых нужно сбросить HTTP-кэш при следующем запросе
|
||||||
|
const needsCacheBust = new Set<number>()
|
||||||
|
|
||||||
interface UserAvatarProps {
|
interface UserAvatarProps {
|
||||||
userId: number
|
userId: number
|
||||||
@@ -10,6 +12,7 @@ interface UserAvatarProps {
|
|||||||
nickname: string
|
nickname: string
|
||||||
size?: 'sm' | 'md' | 'lg'
|
size?: 'sm' | 'md' | 'lg'
|
||||||
className?: string
|
className?: string
|
||||||
|
version?: number // Для принудительного обновления при смене аватара
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
@@ -18,7 +21,7 @@ const sizeClasses = {
|
|||||||
lg: 'w-24 h-24 text-xl',
|
lg: 'w-24 h-24 text-xl',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className = '' }: UserAvatarProps) {
|
export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className = '', version = 0 }: UserAvatarProps) {
|
||||||
const [blobUrl, setBlobUrl] = useState<string | null>(null)
|
const [blobUrl, setBlobUrl] = useState<string | null>(null)
|
||||||
const [failed, setFailed] = useState(false)
|
const [failed, setFailed] = useState(false)
|
||||||
|
|
||||||
@@ -28,16 +31,31 @@ export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем кэш
|
// Если version > 0, значит аватар обновился - сбрасываем кэш
|
||||||
|
const shouldBustCache = version > 0 || needsCacheBust.has(userId)
|
||||||
|
|
||||||
|
// Проверяем кэш только если не нужен bust
|
||||||
|
if (!shouldBustCache) {
|
||||||
const cached = avatarCache.get(userId)
|
const cached = avatarCache.get(userId)
|
||||||
if (cached) {
|
if (cached) {
|
||||||
setBlobUrl(cached)
|
setBlobUrl(cached)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очищаем старый кэш если bust
|
||||||
|
if (shouldBustCache) {
|
||||||
|
const cached = avatarCache.get(userId)
|
||||||
|
if (cached) {
|
||||||
|
URL.revokeObjectURL(cached)
|
||||||
|
avatarCache.delete(userId)
|
||||||
|
}
|
||||||
|
needsCacheBust.delete(userId)
|
||||||
|
}
|
||||||
|
|
||||||
// Загружаем аватарку
|
// Загружаем аватарку
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
usersApi.getAvatarUrl(userId)
|
usersApi.getAvatarUrl(userId, shouldBustCache)
|
||||||
.then(url => {
|
.then(url => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
avatarCache.set(userId, url)
|
avatarCache.set(userId, url)
|
||||||
@@ -53,7 +71,7 @@ export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [userId, hasAvatar])
|
}, [userId, hasAvatar, version])
|
||||||
|
|
||||||
const sizeClass = sizeClasses[size]
|
const sizeClass = sizeClasses[size]
|
||||||
|
|
||||||
@@ -84,4 +102,6 @@ export function clearAvatarCache(userId: number) {
|
|||||||
URL.revokeObjectURL(cached)
|
URL.revokeObjectURL(cached)
|
||||||
avatarCache.delete(userId)
|
avatarCache.delete(userId)
|
||||||
}
|
}
|
||||||
|
// Помечаем, что при следующем запросе нужно сбросить HTTP-кэш браузера
|
||||||
|
needsCacheBust.add(userId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,8 @@ export { Card, CardHeader, CardTitle, CardContent } from './Card'
|
|||||||
export { ToastContainer } from './Toast'
|
export { ToastContainer } from './Toast'
|
||||||
export { ConfirmModal } from './ConfirmModal'
|
export { ConfirmModal } from './ConfirmModal'
|
||||||
export { UserAvatar, clearAvatarCache } from './UserAvatar'
|
export { UserAvatar, clearAvatarCache } from './UserAvatar'
|
||||||
|
|
||||||
|
// New design system components
|
||||||
|
export { GlitchText, GlitchHeading } from './GlitchText'
|
||||||
|
export { NeonButton, GradientButton } from './NeonButton'
|
||||||
|
export { GlassCard, StatsCard, FeatureCard, AnimatedBorderCard } from './GlassCard'
|
||||||
|
|||||||
@@ -2,11 +2,129 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
body {
|
/* ========================================
|
||||||
@apply bg-gray-900 text-gray-100 min-h-screen;
|
CSS Variables
|
||||||
|
======================================== */
|
||||||
|
:root {
|
||||||
|
/* Base colors - slightly warmer dark tones */
|
||||||
|
--color-dark-950: #08090d;
|
||||||
|
--color-dark-900: #0d0e14;
|
||||||
|
--color-dark-800: #14161e;
|
||||||
|
--color-dark-700: #1c1e28;
|
||||||
|
--color-dark-600: #252732;
|
||||||
|
--color-dark-500: #2e313d;
|
||||||
|
|
||||||
|
/* Soft cyan (primary) - gentler on eyes */
|
||||||
|
--color-neon-500: #22d3ee;
|
||||||
|
--color-neon-400: #67e8f9;
|
||||||
|
--color-neon-600: #06b6d4;
|
||||||
|
|
||||||
|
/* Soft violet accent */
|
||||||
|
--color-accent-500: #8b5cf6;
|
||||||
|
--color-accent-600: #7c3aed;
|
||||||
|
--color-accent-700: #6d28d9;
|
||||||
|
|
||||||
|
/* Soft pink highlight - used sparingly */
|
||||||
|
--color-pink-500: #f472b6;
|
||||||
|
|
||||||
|
/* Glow colors - reduced intensity */
|
||||||
|
--glow-neon: 0 0 8px rgba(34, 211, 238, 0.4), 0 0 16px rgba(34, 211, 238, 0.2);
|
||||||
|
--glow-neon-lg: 0 0 12px rgba(34, 211, 238, 0.5), 0 0 24px rgba(34, 211, 238, 0.3);
|
||||||
|
--glow-purple: 0 0 8px rgba(139, 92, 246, 0.4), 0 0 16px rgba(139, 92, 246, 0.2);
|
||||||
|
--glow-pink: 0 0 8px rgba(244, 114, 182, 0.4), 0 0 16px rgba(244, 114, 182, 0.2);
|
||||||
|
|
||||||
|
/* Text glow - subtle */
|
||||||
|
--text-glow-neon: 0 0 8px rgba(34, 211, 238, 0.5), 0 0 16px rgba(34, 211, 238, 0.25);
|
||||||
|
--text-glow-purple: 0 0 8px rgba(139, 92, 246, 0.5), 0 0 16px rgba(139, 92, 246, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar styles */
|
/* ========================================
|
||||||
|
Base Styles
|
||||||
|
======================================== */
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-dark-900 text-gray-100 min-h-screen antialiased;
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(34, 211, 238, 0.015) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(34, 211, 238, 0.015) 1px, transparent 1px);
|
||||||
|
background-size: 50px 50px;
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Noise overlay - can be added to any element */
|
||||||
|
.noise-overlay::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0.03;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9999;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Autofill styles - override browser defaults */
|
||||||
|
input:-webkit-autofill,
|
||||||
|
input:-webkit-autofill:hover,
|
||||||
|
input:-webkit-autofill:focus,
|
||||||
|
input:-webkit-autofill:active {
|
||||||
|
-webkit-box-shadow: 0 0 0 30px #14161e inset !important;
|
||||||
|
-webkit-text-fill-color: #fff !important;
|
||||||
|
caret-color: #fff;
|
||||||
|
transition: background-color 5000s ease-in-out 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Selection Styles
|
||||||
|
======================================== */
|
||||||
|
::selection {
|
||||||
|
background: rgba(34, 211, 238, 0.25);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-moz-selection {
|
||||||
|
background: rgba(34, 211, 238, 0.25);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Custom Scrollbar (Neon Style)
|
||||||
|
======================================== */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-dark-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: linear-gradient(180deg, var(--color-neon-500), var(--color-accent-500));
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: linear-gradient(180deg, var(--color-neon-400), var(--color-accent-600));
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
background: var(--color-dark-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--color-neon-500) var(--color-dark-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar class for specific elements */
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
@@ -16,46 +134,450 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: #4b5563;
|
background: var(--color-dark-500);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
background: #6b7280;
|
background: var(--color-neon-500);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Firefox */
|
/* ========================================
|
||||||
.custom-scrollbar {
|
Glitch Effect
|
||||||
scrollbar-width: thin;
|
======================================== */
|
||||||
scrollbar-color: #4b5563 transparent;
|
.glitch {
|
||||||
|
position: relative;
|
||||||
|
animation: glitch-skew 1s infinite linear alternate-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.glitch::before,
|
||||||
|
.glitch::after {
|
||||||
|
content: attr(data-text);
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glitch::before {
|
||||||
|
left: 2px;
|
||||||
|
text-shadow: -2px 0 rgba(139, 92, 246, 0.7);
|
||||||
|
clip: rect(44px, 450px, 56px, 0);
|
||||||
|
animation: glitch-anim 5s infinite linear alternate-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glitch::after {
|
||||||
|
left: -2px;
|
||||||
|
text-shadow: -2px 0 rgba(34, 211, 238, 0.7), 2px 2px rgba(139, 92, 246, 0.7);
|
||||||
|
clip: rect(44px, 450px, 56px, 0);
|
||||||
|
animation: glitch-anim2 5s infinite linear alternate-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glitch-anim {
|
||||||
|
0% { clip: rect(31px, 9999px, 94px, 0); transform: skew(0.85deg); }
|
||||||
|
5% { clip: rect(70px, 9999px, 71px, 0); transform: skew(0.07deg); }
|
||||||
|
10% { clip: rect(29px, 9999px, 24px, 0); transform: skew(0.22deg); }
|
||||||
|
15% { clip: rect(69px, 9999px, 63px, 0); transform: skew(0.52deg); }
|
||||||
|
20% { clip: rect(13px, 9999px, 71px, 0); transform: skew(0.72deg); }
|
||||||
|
25% { clip: rect(39px, 9999px, 89px, 0); transform: skew(0.24deg); }
|
||||||
|
30% { clip: rect(87px, 9999px, 98px, 0); transform: skew(0.63deg); }
|
||||||
|
35% { clip: rect(63px, 9999px, 16px, 0); transform: skew(0.15deg); }
|
||||||
|
40% { clip: rect(92px, 9999px, 4px, 0); transform: skew(0.83deg); }
|
||||||
|
45% { clip: rect(67px, 9999px, 72px, 0); transform: skew(0.19deg); }
|
||||||
|
50% { clip: rect(43px, 9999px, 21px, 0); transform: skew(0.74deg); }
|
||||||
|
55% { clip: rect(75px, 9999px, 54px, 0); transform: skew(0.28deg); }
|
||||||
|
60% { clip: rect(17px, 9999px, 86px, 0); transform: skew(0.91deg); }
|
||||||
|
65% { clip: rect(51px, 9999px, 32px, 0); transform: skew(0.46deg); }
|
||||||
|
70% { clip: rect(29px, 9999px, 69px, 0); transform: skew(0.38deg); }
|
||||||
|
75% { clip: rect(84px, 9999px, 11px, 0); transform: skew(0.67deg); }
|
||||||
|
80% { clip: rect(38px, 9999px, 82px, 0); transform: skew(0.12deg); }
|
||||||
|
85% { clip: rect(61px, 9999px, 47px, 0); transform: skew(0.54deg); }
|
||||||
|
90% { clip: rect(22px, 9999px, 91px, 0); transform: skew(0.33deg); }
|
||||||
|
95% { clip: rect(79px, 9999px, 28px, 0); transform: skew(0.79deg); }
|
||||||
|
100% { clip: rect(56px, 9999px, 65px, 0); transform: skew(0.41deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glitch-anim2 {
|
||||||
|
0% { clip: rect(65px, 9999px, 100px, 0); transform: skew(0.63deg); }
|
||||||
|
5% { clip: rect(52px, 9999px, 74px, 0); transform: skew(0.29deg); }
|
||||||
|
10% { clip: rect(79px, 9999px, 85px, 0); transform: skew(0.84deg); }
|
||||||
|
15% { clip: rect(43px, 9999px, 27px, 0); transform: skew(0.17deg); }
|
||||||
|
20% { clip: rect(16px, 9999px, 92px, 0); transform: skew(0.56deg); }
|
||||||
|
25% { clip: rect(88px, 9999px, 36px, 0); transform: skew(0.39deg); }
|
||||||
|
30% { clip: rect(32px, 9999px, 68px, 0); transform: skew(0.71deg); }
|
||||||
|
35% { clip: rect(71px, 9999px, 13px, 0); transform: skew(0.23deg); }
|
||||||
|
40% { clip: rect(24px, 9999px, 57px, 0); transform: skew(0.92deg); }
|
||||||
|
45% { clip: rect(83px, 9999px, 41px, 0); transform: skew(0.48deg); }
|
||||||
|
50% { clip: rect(19px, 9999px, 79px, 0); transform: skew(0.35deg); }
|
||||||
|
55% { clip: rect(67px, 9999px, 23px, 0); transform: skew(0.76deg); }
|
||||||
|
60% { clip: rect(45px, 9999px, 96px, 0); transform: skew(0.14deg); }
|
||||||
|
65% { clip: rect(91px, 9999px, 51px, 0); transform: skew(0.58deg); }
|
||||||
|
70% { clip: rect(28px, 9999px, 83px, 0); transform: skew(0.87deg); }
|
||||||
|
75% { clip: rect(76px, 9999px, 19px, 0); transform: skew(0.26deg); }
|
||||||
|
80% { clip: rect(53px, 9999px, 67px, 0); transform: skew(0.69deg); }
|
||||||
|
85% { clip: rect(14px, 9999px, 89px, 0); transform: skew(0.43deg); }
|
||||||
|
90% { clip: rect(62px, 9999px, 34px, 0); transform: skew(0.81deg); }
|
||||||
|
95% { clip: rect(37px, 9999px, 76px, 0); transform: skew(0.52deg); }
|
||||||
|
100% { clip: rect(86px, 9999px, 48px, 0); transform: skew(0.31deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glitch-skew {
|
||||||
|
0% { transform: skew(-2deg); }
|
||||||
|
10% { transform: skew(1deg); }
|
||||||
|
20% { transform: skew(-1deg); }
|
||||||
|
30% { transform: skew(0deg); }
|
||||||
|
40% { transform: skew(2deg); }
|
||||||
|
50% { transform: skew(-1deg); }
|
||||||
|
60% { transform: skew(1deg); }
|
||||||
|
70% { transform: skew(0deg); }
|
||||||
|
80% { transform: skew(-2deg); }
|
||||||
|
90% { transform: skew(1deg); }
|
||||||
|
100% { transform: skew(0deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Simpler glitch for hover states */
|
||||||
|
.glitch-hover:hover {
|
||||||
|
animation: glitch-simple 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glitch-simple {
|
||||||
|
0%, 100% { transform: translate(0); }
|
||||||
|
20% { transform: translate(-2px, 2px); }
|
||||||
|
40% { transform: translate(-2px, -2px); }
|
||||||
|
60% { transform: translate(2px, 2px); }
|
||||||
|
80% { transform: translate(2px, -2px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Neon Glow Effects
|
||||||
|
======================================== */
|
||||||
|
.neon-glow {
|
||||||
|
box-shadow: var(--glow-neon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-glow-lg {
|
||||||
|
box-shadow: var(--glow-neon-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-glow-purple {
|
||||||
|
box-shadow: var(--glow-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-glow-pink {
|
||||||
|
box-shadow: var(--glow-pink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-text {
|
||||||
|
text-shadow: var(--text-glow-neon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neon-text-purple {
|
||||||
|
text-shadow: var(--text-glow-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated glow */
|
||||||
|
.neon-glow-pulse {
|
||||||
|
animation: neon-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes neon-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 6px rgba(34, 211, 238, 0.4), 0 0 12px rgba(34, 211, 238, 0.2);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 10px rgba(34, 211, 238, 0.5), 0 0 20px rgba(34, 211, 238, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Glass Effect (Glassmorphism)
|
||||||
|
======================================== */
|
||||||
|
.glass {
|
||||||
|
background: rgba(18, 18, 26, 0.7);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-dark {
|
||||||
|
background: rgba(10, 10, 15, 0.8);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-neon {
|
||||||
|
background: rgba(20, 22, 30, 0.6);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(34, 211, 238, 0.15);
|
||||||
|
box-shadow: inset 0 0 20px rgba(34, 211, 238, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Gradient Utilities
|
||||||
|
======================================== */
|
||||||
|
.gradient-neon {
|
||||||
|
background: linear-gradient(135deg, #22d3ee, #8b5cf6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-neon-text {
|
||||||
|
background: linear-gradient(135deg, #22d3ee, #8b5cf6);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-pink-purple {
|
||||||
|
background: linear-gradient(135deg, #f472b6, #8b5cf6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-dark {
|
||||||
|
background: linear-gradient(180deg, var(--color-dark-900), var(--color-dark-950));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animated gradient border */
|
||||||
|
.gradient-border {
|
||||||
|
position: relative;
|
||||||
|
background: var(--color-dark-800);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-border::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: -2px;
|
||||||
|
background: linear-gradient(90deg, #22d3ee, #8b5cf6, #f472b6, #22d3ee);
|
||||||
|
background-size: 300% 300%;
|
||||||
|
border-radius: 14px;
|
||||||
|
z-index: -1;
|
||||||
|
animation: gradient-flow 3s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradient-flow {
|
||||||
|
0% { background-position: 0% 50%; }
|
||||||
|
50% { background-position: 100% 50%; }
|
||||||
|
100% { background-position: 0% 50%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Shimmer Effect
|
||||||
|
======================================== */
|
||||||
|
.shimmer {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shimmer::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.1),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
animation: shimmer 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
100% { left: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Component Layer
|
||||||
|
======================================== */
|
||||||
@layer components {
|
@layer components {
|
||||||
|
/* Buttons */
|
||||||
.btn {
|
.btn {
|
||||||
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
|
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply bg-primary-600 hover:bg-primary-700 text-white;
|
@apply bg-neon-500 hover:bg-neon-400 text-dark-900 font-semibold;
|
||||||
|
box-shadow: 0 0 8px rgba(34, 211, 238, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
box-shadow: 0 0 14px rgba(34, 211, 238, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@apply bg-gray-700 hover:bg-gray-600 text-white;
|
@apply bg-dark-600 hover:bg-dark-500 text-white border border-dark-500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
@apply bg-red-600 hover:bg-red-700 text-white;
|
@apply bg-red-600 hover:bg-red-700 text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
@apply bg-transparent hover:bg-dark-700 text-gray-300 hover:text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-neon {
|
||||||
|
@apply relative bg-transparent border-2 border-neon-500 text-neon-500 font-semibold overflow-hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-neon:hover {
|
||||||
|
@apply text-dark-900;
|
||||||
|
background: var(--color-neon-500);
|
||||||
|
box-shadow: 0 0 14px rgba(34, 211, 238, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
.input {
|
.input {
|
||||||
@apply w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
|
@apply w-full px-4 py-3 bg-dark-800 border border-dark-600 rounded-lg text-white placeholder-gray-500 transition-all duration-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
@apply outline-none border-neon-500;
|
||||||
|
box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.1), 0 0 8px rgba(34, 211, 238, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
.card {
|
.card {
|
||||||
@apply bg-gray-800 rounded-xl p-6 shadow-lg;
|
@apply bg-dark-800 rounded-xl p-6 border border-dark-600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-glass {
|
||||||
|
@apply rounded-xl p-6;
|
||||||
|
background: rgba(20, 22, 30, 0.7);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover {
|
||||||
|
@apply transition-all duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover:hover {
|
||||||
|
@apply -translate-y-1;
|
||||||
|
box-shadow: 0 10px 40px rgba(34, 211, 238, 0.08);
|
||||||
|
border-color: rgba(34, 211, 238, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
.link {
|
.link {
|
||||||
@apply text-primary-400 hover:text-primary-300 transition-colors;
|
@apply text-neon-500 hover:text-neon-400 transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-neon {
|
||||||
|
@apply bg-neon-500/20 text-neon-400 border border-neon-500/30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-purple {
|
||||||
|
@apply bg-accent-500/20 text-accent-400 border border-accent-500/30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-pink {
|
||||||
|
@apply bg-pink-500/20 text-pink-400 border border-pink-500/30;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dividers */
|
||||||
|
.divider {
|
||||||
|
@apply border-t border-dark-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider-glow {
|
||||||
|
@apply border-t border-neon-500/30;
|
||||||
|
box-shadow: 0 0 8px rgba(34, 211, 238, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Utility Animations
|
||||||
|
======================================== */
|
||||||
|
.hover-lift {
|
||||||
|
@apply transition-transform duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-lift:hover {
|
||||||
|
@apply -translate-y-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-glow {
|
||||||
|
@apply transition-shadow duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-glow:hover {
|
||||||
|
box-shadow: 0 0 14px rgba(34, 211, 238, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-border-glow {
|
||||||
|
@apply transition-all duration-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-border-glow:hover {
|
||||||
|
border-color: rgba(34, 211, 238, 0.4);
|
||||||
|
box-shadow: 0 0 12px rgba(34, 211, 238, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stagger children animations */
|
||||||
|
.stagger-children > * {
|
||||||
|
@apply animate-slide-in-up;
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stagger-children > *:nth-child(1) { animation-delay: 0ms; }
|
||||||
|
.stagger-children > *:nth-child(2) { animation-delay: 50ms; }
|
||||||
|
.stagger-children > *:nth-child(3) { animation-delay: 100ms; }
|
||||||
|
.stagger-children > *:nth-child(4) { animation-delay: 150ms; }
|
||||||
|
.stagger-children > *:nth-child(5) { animation-delay: 200ms; }
|
||||||
|
.stagger-children > *:nth-child(6) { animation-delay: 250ms; }
|
||||||
|
.stagger-children > *:nth-child(7) { animation-delay: 300ms; }
|
||||||
|
.stagger-children > *:nth-child(8) { animation-delay: 350ms; }
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Skeleton Loading
|
||||||
|
======================================== */
|
||||||
|
.skeleton {
|
||||||
|
@apply relative overflow-hidden bg-dark-700 rounded;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.05),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
animation: skeleton-pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes skeleton-pulse {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Focus States (Accessibility)
|
||||||
|
======================================== */
|
||||||
|
.focus-ring {
|
||||||
|
@apply focus:outline-none focus:ring-2 focus:ring-neon-500 focus:ring-offset-2 focus:ring-offset-dark-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduced motion support */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { useState, useEffect } from 'react'
|
|||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { assignmentsApi } from '@/api'
|
import { assignmentsApi } from '@/api'
|
||||||
import type { AssignmentDetail } from '@/types'
|
import type { AssignmentDetail } from '@/types'
|
||||||
import { Card, CardContent, Button } from '@/components/ui'
|
import { GlassCard, NeonButton } from '@/components/ui'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { useToast } from '@/store/toast'
|
import { useToast } from '@/store/toast'
|
||||||
import {
|
import {
|
||||||
ArrowLeft, Loader2, ExternalLink, Image, MessageSquare,
|
ArrowLeft, Loader2, ExternalLink, Image, MessageSquare,
|
||||||
ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle,
|
ThumbsUp, ThumbsDown, AlertTriangle, Clock, CheckCircle, XCircle,
|
||||||
Send, Flag
|
Send, Flag, Gamepad2, Zap, Trophy
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
export function AssignmentDetailPage() {
|
export function AssignmentDetailPage() {
|
||||||
@@ -142,137 +142,167 @@ export function AssignmentDetailPage() {
|
|||||||
return `${hours}ч ${minutes}м`
|
return `${hours}ч ${minutes}м`
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
const getStatusConfig = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return (
|
return {
|
||||||
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full text-sm flex items-center gap-1">
|
color: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||||
<CheckCircle className="w-4 h-4" /> Выполнено
|
icon: <CheckCircle className="w-4 h-4" />,
|
||||||
</span>
|
text: 'Выполнено',
|
||||||
)
|
}
|
||||||
case 'dropped':
|
case 'dropped':
|
||||||
return (
|
return {
|
||||||
<span className="px-3 py-1 bg-red-500/20 text-red-400 rounded-full text-sm flex items-center gap-1">
|
color: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||||
<XCircle className="w-4 h-4" /> Пропущено
|
icon: <XCircle className="w-4 h-4" />,
|
||||||
</span>
|
text: 'Пропущено',
|
||||||
)
|
}
|
||||||
case 'returned':
|
case 'returned':
|
||||||
return (
|
return {
|
||||||
<span className="px-3 py-1 bg-orange-500/20 text-orange-400 rounded-full text-sm flex items-center gap-1">
|
color: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
|
||||||
<AlertTriangle className="w-4 h-4" /> Возвращено
|
icon: <AlertTriangle className="w-4 h-4" />,
|
||||||
</span>
|
text: 'Возвращено',
|
||||||
)
|
}
|
||||||
default:
|
default:
|
||||||
return (
|
return {
|
||||||
<span className="px-3 py-1 bg-blue-500/20 text-blue-400 rounded-full text-sm">
|
color: 'bg-neon-500/20 text-neon-400 border-neon-500/30',
|
||||||
Активно
|
icon: <Zap className="w-4 h-4" />,
|
||||||
</span>
|
text: 'Активно',
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-24">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
|
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
|
||||||
|
<p className="text-gray-400">Загрузка...</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error || !assignment) {
|
if (error || !assignment) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto text-center py-12">
|
<div className="max-w-2xl mx-auto">
|
||||||
<p className="text-red-400 mb-4">{error || 'Задание не найдено'}</p>
|
<GlassCard className="text-center py-12">
|
||||||
<Button onClick={() => navigate(-1)}>Назад</Button>
|
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-red-500/10 border border-red-500/30 flex items-center justify-center">
|
||||||
|
<AlertTriangle className="w-8 h-8 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 mb-6">{error || 'Задание не найдено'}</p>
|
||||||
|
<NeonButton variant="outline" onClick={() => navigate(-1)}>
|
||||||
|
Назад
|
||||||
|
</NeonButton>
|
||||||
|
</GlassCard>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const dispute = assignment.dispute
|
const dispute = assignment.dispute
|
||||||
|
const status = getStatusConfig(assignment.status)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4">
|
||||||
<button onClick={() => navigate(-1)} className="text-gray-400 hover:text-white">
|
<button
|
||||||
<ArrowLeft className="w-6 h-6" />
|
onClick={() => navigate(-1)}
|
||||||
|
className="w-10 h-10 rounded-xl bg-dark-700 border border-dark-600 flex items-center justify-center text-gray-400 hover:text-white hover:border-neon-500/30 transition-all"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
<div>
|
||||||
<h1 className="text-xl font-bold text-white">Детали выполнения</h1>
|
<h1 className="text-xl font-bold text-white">Детали выполнения</h1>
|
||||||
|
<p className="text-sm text-gray-400">Просмотр доказательства</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Challenge info */}
|
{/* Challenge info */}
|
||||||
<Card className="mb-6">
|
<GlassCard variant="neon">
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 border border-neon-500/20 flex items-center justify-center">
|
||||||
|
<Gamepad2 className="w-7 h-7 text-neon-400" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-400 text-sm">{assignment.challenge.game.title}</p>
|
<p className="text-gray-400 text-sm">{assignment.challenge.game.title}</p>
|
||||||
<h2 className="text-xl font-bold text-white">{assignment.challenge.title}</h2>
|
<h2 className="text-xl font-bold text-white">{assignment.challenge.title}</h2>
|
||||||
</div>
|
</div>
|
||||||
{getStatusBadge(assignment.status)}
|
</div>
|
||||||
|
<span className={`px-3 py-1.5 rounded-full text-sm font-medium border flex items-center gap-1.5 ${status.color}`}>
|
||||||
|
{status.icon}
|
||||||
|
{status.text}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-300 mb-4">{assignment.challenge.description}</p>
|
<p className="text-gray-300 mb-4">{assignment.challenge.description}</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mb-4">
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full text-sm">
|
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30 flex items-center gap-1.5">
|
||||||
|
<Trophy className="w-4 h-4" />
|
||||||
+{assignment.challenge.points} очков
|
+{assignment.challenge.points} очков
|
||||||
</span>
|
</span>
|
||||||
<span className="px-3 py-1 bg-gray-700 text-gray-300 rounded-full text-sm">
|
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600">
|
||||||
{assignment.challenge.difficulty}
|
{assignment.challenge.difficulty}
|
||||||
</span>
|
</span>
|
||||||
{assignment.challenge.estimated_time && (
|
{assignment.challenge.estimated_time && (
|
||||||
<span className="px-3 py-1 bg-gray-700 text-gray-300 rounded-full text-sm">
|
<span className="px-3 py-1.5 bg-dark-700 text-gray-300 rounded-lg text-sm border border-dark-600 flex items-center gap-1.5">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
~{assignment.challenge.estimated_time} мин
|
~{assignment.challenge.estimated_time} мин
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-gray-400 space-y-1">
|
<div className="pt-4 border-t border-dark-600 text-sm text-gray-400 space-y-1">
|
||||||
<p>
|
<p>
|
||||||
<strong>Выполнил:</strong> {assignment.participant.nickname}
|
<span className="text-gray-500">Выполнил:</span>{' '}
|
||||||
|
<span className="text-white">{assignment.participant.nickname}</span>
|
||||||
</p>
|
</p>
|
||||||
{assignment.completed_at && (
|
{assignment.completed_at && (
|
||||||
<p>
|
<p>
|
||||||
<strong>Дата:</strong> {formatDate(assignment.completed_at)}
|
<span className="text-gray-500">Дата:</span>{' '}
|
||||||
|
<span className="text-white">{formatDate(assignment.completed_at)}</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{assignment.points_earned > 0 && (
|
{assignment.points_earned > 0 && (
|
||||||
<p>
|
<p>
|
||||||
<strong>Получено очков:</strong> {assignment.points_earned}
|
<span className="text-gray-500">Получено очков:</span>{' '}
|
||||||
|
<span className="text-neon-400 font-semibold">{assignment.points_earned}</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</GlassCard>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Proof section */}
|
{/* Proof section */}
|
||||||
<Card className="mb-6">
|
<GlassCard>
|
||||||
<CardContent>
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
|
||||||
<Image className="w-5 h-5" />
|
<Image className="w-5 h-5 text-accent-400" />
|
||||||
Доказательство
|
</div>
|
||||||
</h3>
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">Доказательство</h3>
|
||||||
|
<p className="text-sm text-gray-400">Пруф выполнения задания</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Proof media (image or video) */}
|
{/* Proof media (image or video) */}
|
||||||
{assignment.proof_image_url && (
|
{assignment.proof_image_url && (
|
||||||
<div className="mb-4">
|
<div className="mb-4 rounded-xl overflow-hidden border border-dark-600">
|
||||||
{proofMediaBlobUrl ? (
|
{proofMediaBlobUrl ? (
|
||||||
proofMediaType === 'video' ? (
|
proofMediaType === 'video' ? (
|
||||||
<video
|
<video
|
||||||
src={proofMediaBlobUrl}
|
src={proofMediaBlobUrl}
|
||||||
controls
|
controls
|
||||||
className="w-full rounded-lg max-h-96 bg-gray-900"
|
className="w-full max-h-96 bg-dark-900"
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
src={proofMediaBlobUrl}
|
src={proofMediaBlobUrl}
|
||||||
alt="Proof"
|
alt="Proof"
|
||||||
className="w-full rounded-lg max-h-96 object-contain bg-gray-900"
|
className="w-full max-h-96 object-contain bg-dark-900"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-48 bg-gray-900 rounded-lg flex items-center justify-center">
|
<div className="w-full h-48 bg-dark-900 flex items-center justify-center">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-gray-500" />
|
<Loader2 className="w-8 h-8 animate-spin text-gray-500" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -286,7 +316,7 @@ export function AssignmentDetailPage() {
|
|||||||
href={assignment.proof_url}
|
href={assignment.proof_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-2 text-primary-400 hover:text-primary-300"
|
className="flex items-center gap-2 text-neon-400 hover:text-neon-300 transition-colors"
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-4 h-4" />
|
<ExternalLink className="w-4 h-4" />
|
||||||
{assignment.proof_url}
|
{assignment.proof_url}
|
||||||
@@ -296,42 +326,45 @@ export function AssignmentDetailPage() {
|
|||||||
|
|
||||||
{/* Proof comment */}
|
{/* Proof comment */}
|
||||||
{assignment.proof_comment && (
|
{assignment.proof_comment && (
|
||||||
<div className="p-3 bg-gray-900 rounded-lg">
|
<div className="p-4 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||||
<p className="text-sm text-gray-400 mb-1">Комментарий:</p>
|
<p className="text-sm text-gray-400 mb-1">Комментарий:</p>
|
||||||
<p className="text-white">{assignment.proof_comment}</p>
|
<p className="text-white">{assignment.proof_comment}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!assignment.proof_image_url && !assignment.proof_url && (
|
{!assignment.proof_image_url && !assignment.proof_url && (
|
||||||
<p className="text-gray-500 text-center py-4">Пруф не предоставлен</p>
|
<div className="text-center py-8">
|
||||||
|
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-dark-700 flex items-center justify-center">
|
||||||
|
<Image className="w-6 h-6 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500">Пруф не предоставлен</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</GlassCard>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Dispute button */}
|
{/* Dispute button */}
|
||||||
{assignment.can_dispute && !dispute && !showDisputeForm && (
|
{assignment.can_dispute && !dispute && !showDisputeForm && (
|
||||||
<Button
|
<button
|
||||||
variant="danger"
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-semibold transition-all duration-200 border-2 border-red-500/50 text-red-400 bg-transparent hover:bg-red-500/10 hover:border-red-500"
|
||||||
className="w-full mb-6"
|
|
||||||
onClick={() => setShowDisputeForm(true)}
|
onClick={() => setShowDisputeForm(true)}
|
||||||
>
|
>
|
||||||
<Flag className="w-4 h-4 mr-2" />
|
<Flag className="w-4 h-4" />
|
||||||
Оспорить выполнение
|
Оспорить выполнение
|
||||||
</Button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dispute creation form */}
|
{/* Dispute creation form */}
|
||||||
{showDisputeForm && !dispute && (
|
{showDisputeForm && !dispute && (
|
||||||
<Card className="mb-6 border-red-500/50">
|
<GlassCard className="border-red-500/30">
|
||||||
<CardContent>
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<h3 className="text-lg font-bold text-red-400 mb-4 flex items-center gap-2">
|
<div className="w-10 h-10 rounded-xl bg-red-500/20 flex items-center justify-center">
|
||||||
<AlertTriangle className="w-5 h-5" />
|
<AlertTriangle className="w-5 h-5 text-red-400" />
|
||||||
Оспорить выполнение
|
</div>
|
||||||
</h3>
|
<div>
|
||||||
|
<h3 className="font-semibold text-red-400">Оспорить выполнение</h3>
|
||||||
<p className="text-gray-400 text-sm mb-4">
|
<p className="text-sm text-gray-400">У участников будет 24 часа для голосования</p>
|
||||||
Опишите причину оспаривания. После создания у участников будет 24 часа для голосования.
|
</div>
|
||||||
</p>
|
</div>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
className="input w-full min-h-[100px] resize-none mb-4"
|
className="input w-full min-h-[100px] resize-none mb-4"
|
||||||
@@ -341,115 +374,120 @@ export function AssignmentDetailPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button
|
<NeonButton
|
||||||
variant="danger"
|
className="flex-1 border-red-500/50 bg-red-500/10 hover:bg-red-500/20 text-red-400"
|
||||||
className="flex-1"
|
|
||||||
onClick={handleCreateDispute}
|
onClick={handleCreateDispute}
|
||||||
isLoading={isCreatingDispute}
|
isLoading={isCreatingDispute}
|
||||||
disabled={disputeReason.trim().length < 10}
|
disabled={disputeReason.trim().length < 10}
|
||||||
>
|
>
|
||||||
Оспорить
|
Оспорить
|
||||||
</Button>
|
</NeonButton>
|
||||||
<Button
|
<NeonButton
|
||||||
variant="secondary"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowDisputeForm(false)
|
setShowDisputeForm(false)
|
||||||
setDisputeReason('')
|
setDisputeReason('')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</NeonButton>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</GlassCard>
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dispute section */}
|
{/* Dispute section */}
|
||||||
{dispute && (
|
{dispute && (
|
||||||
<Card className={`mb-6 ${dispute.status === 'open' ? 'border-yellow-500/50' : ''}`}>
|
<GlassCard className={dispute.status === 'open' ? 'border-yellow-500/30' : ''}>
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<h3 className="text-lg font-bold text-yellow-400 flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<AlertTriangle className="w-5 h-5" />
|
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center">
|
||||||
Оспаривание
|
<AlertTriangle className="w-5 h-5 text-yellow-400" />
|
||||||
</h3>
|
</div>
|
||||||
|
<h3 className="font-semibold text-yellow-400">Оспаривание</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
{dispute.status === 'open' ? (
|
{dispute.status === 'open' ? (
|
||||||
<span className="px-3 py-1 bg-yellow-500/20 text-yellow-400 rounded-full text-sm flex items-center gap-1">
|
<span className="px-3 py-1.5 bg-yellow-500/20 text-yellow-400 rounded-lg text-sm font-medium border border-yellow-500/30 flex items-center gap-1.5">
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
{getTimeRemaining(dispute.expires_at)}
|
{getTimeRemaining(dispute.expires_at)}
|
||||||
</span>
|
</span>
|
||||||
) : dispute.status === 'valid' ? (
|
) : dispute.status === 'valid' ? (
|
||||||
<span className="px-3 py-1 bg-green-500/20 text-green-400 rounded-full text-sm flex items-center gap-1">
|
<span className="px-3 py-1.5 bg-green-500/20 text-green-400 rounded-lg text-sm font-medium border border-green-500/30 flex items-center gap-1.5">
|
||||||
<CheckCircle className="w-4 h-4" />
|
<CheckCircle className="w-4 h-4" />
|
||||||
Пруф валиден
|
Пруф валиден
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="px-3 py-1 bg-red-500/20 text-red-400 rounded-full text-sm flex items-center gap-1">
|
<span className="px-3 py-1.5 bg-red-500/20 text-red-400 rounded-lg text-sm font-medium border border-red-500/30 flex items-center gap-1.5">
|
||||||
<XCircle className="w-4 h-4" />
|
<XCircle className="w-4 h-4" />
|
||||||
Пруф невалиден
|
Пруф невалиден
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4 text-sm text-gray-400">
|
||||||
<p className="text-sm text-gray-400">
|
<p>
|
||||||
Открыл: <span className="text-white">{dispute.raised_by.nickname}</span>
|
Открыл: <span className="text-white">{dispute.raised_by.nickname}</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-400">
|
<p>
|
||||||
Дата: <span className="text-white">{formatDate(dispute.created_at)}</span>
|
Дата: <span className="text-white">{formatDate(dispute.created_at)}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-3 bg-gray-900 rounded-lg mb-4">
|
<div className="p-4 bg-dark-700/50 rounded-xl border border-dark-600 mb-4">
|
||||||
<p className="text-sm text-gray-400 mb-1">Причина:</p>
|
<p className="text-sm text-gray-400 mb-1">Причина:</p>
|
||||||
<p className="text-white">{dispute.reason}</p>
|
<p className="text-white">{dispute.reason}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Voting section */}
|
{/* Voting section */}
|
||||||
{dispute.status === 'open' && (
|
{dispute.status === 'open' && (
|
||||||
<div className="mb-4">
|
<div className="mb-6 p-4 bg-dark-700/30 rounded-xl border border-dark-600">
|
||||||
<h4 className="text-sm font-medium text-gray-300 mb-3">Голосование</h4>
|
<h4 className="text-sm font-semibold text-white mb-4">Голосование</h4>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 mb-3">
|
<div className="flex items-center gap-6 mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ThumbsUp className="w-5 h-5 text-green-500" />
|
<div className="w-8 h-8 rounded-lg bg-green-500/20 flex items-center justify-center">
|
||||||
<span className="text-green-400 font-medium">{dispute.votes_valid}</span>
|
<ThumbsUp className="w-4 h-4 text-green-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-green-400 font-bold text-lg">{dispute.votes_valid}</span>
|
||||||
<span className="text-gray-500 text-sm">валидно</span>
|
<span className="text-gray-500 text-sm">валидно</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ThumbsDown className="w-5 h-5 text-red-500" />
|
<div className="w-8 h-8 rounded-lg bg-red-500/20 flex items-center justify-center">
|
||||||
<span className="text-red-400 font-medium">{dispute.votes_invalid}</span>
|
<ThumbsDown className="w-4 h-4 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<span className="text-red-400 font-bold text-lg">{dispute.votes_invalid}</span>
|
||||||
<span className="text-gray-500 text-sm">невалидно</span>
|
<span className="text-gray-500 text-sm">невалидно</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button
|
<NeonButton
|
||||||
variant={dispute.my_vote === true ? 'primary' : 'secondary'}
|
className={`flex-1 ${dispute.my_vote === true ? 'bg-green-500/20 border-green-500/50 text-green-400' : ''}`}
|
||||||
className="flex-1"
|
variant="outline"
|
||||||
onClick={() => handleVote(true)}
|
onClick={() => handleVote(true)}
|
||||||
isLoading={isVoting}
|
isLoading={isVoting}
|
||||||
disabled={isVoting}
|
disabled={isVoting}
|
||||||
|
icon={<ThumbsUp className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
<ThumbsUp className="w-4 h-4 mr-2" />
|
|
||||||
Валидно
|
Валидно
|
||||||
</Button>
|
</NeonButton>
|
||||||
<Button
|
<NeonButton
|
||||||
variant={dispute.my_vote === false ? 'danger' : 'secondary'}
|
className={`flex-1 ${dispute.my_vote === false ? 'bg-red-500/20 border-red-500/50 text-red-400' : ''}`}
|
||||||
className="flex-1"
|
variant="outline"
|
||||||
onClick={() => handleVote(false)}
|
onClick={() => handleVote(false)}
|
||||||
isLoading={isVoting}
|
isLoading={isVoting}
|
||||||
disabled={isVoting}
|
disabled={isVoting}
|
||||||
|
icon={<ThumbsDown className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
<ThumbsDown className="w-4 h-4 mr-2" />
|
|
||||||
Невалидно
|
Невалидно
|
||||||
</Button>
|
</NeonButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dispute.my_vote !== null && (
|
{dispute.my_vote !== null && (
|
||||||
<p className="text-sm text-gray-500 mt-2 text-center">
|
<p className="text-sm text-gray-500 mt-3 text-center">
|
||||||
Вы проголосовали: {dispute.my_vote ? 'валидно' : 'невалидно'}
|
Вы проголосовали: <span className={dispute.my_vote ? 'text-green-400' : 'text-red-400'}>
|
||||||
|
{dispute.my_vote ? 'валидно' : 'невалидно'}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -457,17 +495,19 @@ export function AssignmentDetailPage() {
|
|||||||
|
|
||||||
{/* Comments section */}
|
{/* Comments section */}
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium text-gray-300 mb-3 flex items-center gap-2">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<MessageSquare className="w-4 h-4" />
|
<MessageSquare className="w-4 h-4 text-gray-400" />
|
||||||
|
<h4 className="text-sm font-semibold text-white">
|
||||||
Обсуждение ({dispute.comments.length})
|
Обсуждение ({dispute.comments.length})
|
||||||
</h4>
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
{dispute.comments.length > 0 && (
|
{dispute.comments.length > 0 && (
|
||||||
<div className="space-y-3 mb-4 max-h-60 overflow-y-auto">
|
<div className="space-y-3 mb-4 max-h-60 overflow-y-auto custom-scrollbar">
|
||||||
{dispute.comments.map((comment) => (
|
{dispute.comments.map((comment) => (
|
||||||
<div key={comment.id} className="p-3 bg-gray-900 rounded-lg">
|
<div key={comment.id} className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className={`font-medium ${comment.user.id === user?.id ? 'text-primary-400' : 'text-white'}`}>
|
<span className={`font-medium ${comment.user.id === user?.id ? 'text-neon-400' : 'text-white'}`}>
|
||||||
{comment.user.nickname}
|
{comment.user.nickname}
|
||||||
{comment.user.id === user?.id && ' (Вы)'}
|
{comment.user.id === user?.id && ' (Вы)'}
|
||||||
</span>
|
</span>
|
||||||
@@ -497,18 +537,16 @@ export function AssignmentDetailPage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<NeonButton
|
||||||
onClick={handleAddComment}
|
onClick={handleAddComment}
|
||||||
isLoading={isAddingComment}
|
isLoading={isAddingComment}
|
||||||
disabled={!commentText.trim()}
|
disabled={!commentText.trim()}
|
||||||
>
|
icon={<Send className="w-4 h-4" />}
|
||||||
<Send className="w-4 h-4" />
|
/>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</GlassCard>
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { useForm } from 'react-hook-form'
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { marathonsApi } from '@/api'
|
import { marathonsApi } from '@/api'
|
||||||
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui'
|
import { NeonButton, Input, GlassCard } from '@/components/ui'
|
||||||
import { Globe, Lock, Users, UserCog, ArrowLeft } from 'lucide-react'
|
import { Globe, Lock, Users, UserCog, ArrowLeft, Gamepad2, AlertCircle, Sparkles, Calendar, Clock } from 'lucide-react'
|
||||||
import type { GameProposalMode } from '@/types'
|
import type { GameProposalMode } from '@/types'
|
||||||
|
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
@@ -64,25 +64,38 @@ export function CreateMarathonPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-lg mx-auto">
|
<div className="max-w-xl mx-auto">
|
||||||
{/* Back button */}
|
{/* Back button */}
|
||||||
<Link to="/marathons" className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
|
<Link
|
||||||
<ArrowLeft className="w-4 h-4" />
|
to="/marathons"
|
||||||
|
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>
|
</Link>
|
||||||
|
|
||||||
<Card>
|
<GlassCard variant="neon">
|
||||||
<CardHeader>
|
{/* Header */}
|
||||||
<CardTitle>Создать марафон</CardTitle>
|
<div className="flex items-center gap-4 mb-8">
|
||||||
</CardHeader>
|
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center border border-neon-500/30">
|
||||||
<CardContent>
|
<Gamepad2 className="w-7 h-7 text-neon-400" />
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Создать марафон</h1>
|
||||||
|
<p className="text-gray-400 text-sm">Настройте свой игровой марафон</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-3 bg-red-500/20 border border-red-500 rounded-lg text-red-500 text-sm">
|
<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">
|
||||||
{error}
|
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Basic info */}
|
||||||
|
<div className="space-y-4">
|
||||||
<Input
|
<Input
|
||||||
label="Название"
|
label="Название"
|
||||||
placeholder="Введите название марафона"
|
placeholder="Введите название марафона"
|
||||||
@@ -91,132 +104,209 @@ export function CreateMarathonPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
Описание (необязательно)
|
Описание (необязательно)
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
className="input min-h-[100px] resize-none"
|
className="input min-h-[100px] resize-none"
|
||||||
placeholder="Введите описание"
|
placeholder="Расскажите о вашем марафоне..."
|
||||||
{...register('description')}
|
{...register('description')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Input
|
{/* Date and duration */}
|
||||||
label="Дата начала"
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
|
||||||
|
<Calendar className="w-4 h-4 text-neon-400" />
|
||||||
|
Дата начала
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
error={errors.start_date?.message}
|
className="input w-full"
|
||||||
{...register('start_date')}
|
{...register('start_date')}
|
||||||
/>
|
/>
|
||||||
|
{errors.start_date && (
|
||||||
|
<p className="text-red-400 text-xs mt-1">{errors.start_date.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Input
|
<div>
|
||||||
label="Длительность (дней)"
|
<label className="flex items-center gap-2 text-sm font-medium text-gray-300 mb-2">
|
||||||
|
<Clock className="w-4 h-4 text-accent-400" />
|
||||||
|
Длительность (дней)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
error={errors.duration_days?.message}
|
className="input w-full"
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
{...register('duration_days', { valueAsNumber: true })}
|
{...register('duration_days', { valueAsNumber: true })}
|
||||||
/>
|
/>
|
||||||
|
{errors.duration_days && (
|
||||||
|
<p className="text-red-400 text-xs mt-1">{errors.duration_days.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Тип марафона */}
|
{/* Marathon type */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-300 mb-3">
|
||||||
Тип марафона
|
Тип марафона
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setValue('is_public', false)}
|
onClick={() => setValue('is_public', false)}
|
||||||
className={`p-3 rounded-lg border-2 transition-all ${
|
className={`
|
||||||
!isPublic
|
relative p-4 rounded-xl border-2 transition-all duration-300 text-left group
|
||||||
? 'border-primary-500 bg-primary-500/10'
|
${!isPublic
|
||||||
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
|
? 'border-neon-500/50 bg-neon-500/10 shadow-[0_0_14px_rgba(34,211,238,0.08)]'
|
||||||
}`}
|
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
|
||||||
|
}
|
||||||
|
`}
|
||||||
>
|
>
|
||||||
<Lock className={`w-5 h-5 mx-auto mb-1 ${!isPublic ? 'text-primary-400' : 'text-gray-400'}`} />
|
<div className={`
|
||||||
<div className={`text-sm font-medium ${!isPublic ? 'text-white' : 'text-gray-300'}`}>
|
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
|
||||||
|
${!isPublic ? 'bg-neon-500/20' : 'bg-dark-600'}
|
||||||
|
`}>
|
||||||
|
<Lock className={`w-5 h-5 ${!isPublic ? 'text-neon-400' : 'text-gray-400'}`} />
|
||||||
|
</div>
|
||||||
|
<div className={`font-semibold mb-1 ${!isPublic ? 'text-white' : 'text-gray-300'}`}>
|
||||||
Закрытый
|
Закрытый
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
<div className="text-xs text-gray-500">
|
||||||
Вход по коду
|
Вход только по коду приглашения
|
||||||
</div>
|
</div>
|
||||||
|
{!isPublic && (
|
||||||
|
<div className="absolute top-3 right-3">
|
||||||
|
<Sparkles className="w-4 h-4 text-neon-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setValue('is_public', true)}
|
onClick={() => setValue('is_public', true)}
|
||||||
className={`p-3 rounded-lg border-2 transition-all ${
|
className={`
|
||||||
isPublic
|
relative p-4 rounded-xl border-2 transition-all duration-300 text-left group
|
||||||
? 'border-primary-500 bg-primary-500/10'
|
${isPublic
|
||||||
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
|
? 'border-accent-500/50 bg-accent-500/10 shadow-[0_0_14px_rgba(139,92,246,0.08)]'
|
||||||
}`}
|
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
|
||||||
|
}
|
||||||
|
`}
|
||||||
>
|
>
|
||||||
<Globe className={`w-5 h-5 mx-auto mb-1 ${isPublic ? 'text-primary-400' : 'text-gray-400'}`} />
|
<div className={`
|
||||||
<div className={`text-sm font-medium ${isPublic ? 'text-white' : 'text-gray-300'}`}>
|
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
|
||||||
|
${isPublic ? 'bg-accent-500/20' : 'bg-dark-600'}
|
||||||
|
`}>
|
||||||
|
<Globe className={`w-5 h-5 ${isPublic ? 'text-accent-400' : 'text-gray-400'}`} />
|
||||||
|
</div>
|
||||||
|
<div className={`font-semibold mb-1 ${isPublic ? 'text-white' : 'text-gray-300'}`}>
|
||||||
Открытый
|
Открытый
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
<div className="text-xs text-gray-500">
|
||||||
Виден всем
|
Виден всем пользователям
|
||||||
</div>
|
</div>
|
||||||
|
{isPublic && (
|
||||||
|
<div className="absolute top-3 right-3">
|
||||||
|
<Sparkles className="w-4 h-4 text-accent-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Кто может предлагать игры */}
|
{/* Game proposal mode */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-300 mb-3">
|
||||||
Кто может предлагать игры
|
Кто может предлагать игры
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setValue('game_proposal_mode', 'all_participants')}
|
onClick={() => setValue('game_proposal_mode', 'all_participants')}
|
||||||
className={`p-3 rounded-lg border-2 transition-all ${
|
className={`
|
||||||
gameProposalMode === 'all_participants'
|
relative p-4 rounded-xl border-2 transition-all duration-300 text-left
|
||||||
? 'border-primary-500 bg-primary-500/10'
|
${gameProposalMode === 'all_participants'
|
||||||
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
|
? 'border-neon-500/50 bg-neon-500/10 shadow-[0_0_14px_rgba(34,211,238,0.08)]'
|
||||||
}`}
|
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
|
||||||
|
}
|
||||||
|
`}
|
||||||
>
|
>
|
||||||
<Users className={`w-5 h-5 mx-auto mb-1 ${gameProposalMode === 'all_participants' ? 'text-primary-400' : 'text-gray-400'}`} />
|
<div className={`
|
||||||
<div className={`text-sm font-medium ${gameProposalMode === 'all_participants' ? 'text-white' : 'text-gray-300'}`}>
|
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
|
||||||
|
${gameProposalMode === 'all_participants' ? 'bg-neon-500/20' : 'bg-dark-600'}
|
||||||
|
`}>
|
||||||
|
<Users className={`w-5 h-5 ${gameProposalMode === 'all_participants' ? 'text-neon-400' : 'text-gray-400'}`} />
|
||||||
|
</div>
|
||||||
|
<div className={`font-semibold mb-1 ${gameProposalMode === 'all_participants' ? 'text-white' : 'text-gray-300'}`}>
|
||||||
Все участники
|
Все участники
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
<div className="text-xs text-gray-500">
|
||||||
С модерацией
|
С модерацией организатором
|
||||||
</div>
|
</div>
|
||||||
|
{gameProposalMode === 'all_participants' && (
|
||||||
|
<div className="absolute top-3 right-3">
|
||||||
|
<Sparkles className="w-4 h-4 text-neon-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setValue('game_proposal_mode', 'organizer_only')}
|
onClick={() => setValue('game_proposal_mode', 'organizer_only')}
|
||||||
className={`p-3 rounded-lg border-2 transition-all ${
|
className={`
|
||||||
gameProposalMode === 'organizer_only'
|
relative p-4 rounded-xl border-2 transition-all duration-300 text-left
|
||||||
? 'border-primary-500 bg-primary-500/10'
|
${gameProposalMode === 'organizer_only'
|
||||||
: 'border-gray-700 bg-gray-800 hover:border-gray-600'
|
? 'border-accent-500/50 bg-accent-500/10 shadow-[0_0_14px_rgba(139,92,246,0.08)]'
|
||||||
}`}
|
: 'border-dark-600 bg-dark-700/50 hover:border-dark-500'
|
||||||
|
}
|
||||||
|
`}
|
||||||
>
|
>
|
||||||
<UserCog className={`w-5 h-5 mx-auto mb-1 ${gameProposalMode === 'organizer_only' ? 'text-primary-400' : 'text-gray-400'}`} />
|
<div className={`
|
||||||
<div className={`text-sm font-medium ${gameProposalMode === 'organizer_only' ? 'text-white' : 'text-gray-300'}`}>
|
w-10 h-10 rounded-xl mb-3 flex items-center justify-center transition-colors
|
||||||
|
${gameProposalMode === 'organizer_only' ? 'bg-accent-500/20' : 'bg-dark-600'}
|
||||||
|
`}>
|
||||||
|
<UserCog className={`w-5 h-5 ${gameProposalMode === 'organizer_only' ? 'text-accent-400' : 'text-gray-400'}`} />
|
||||||
|
</div>
|
||||||
|
<div className={`font-semibold mb-1 ${gameProposalMode === 'organizer_only' ? 'text-white' : 'text-gray-300'}`}>
|
||||||
Только организатор
|
Только организатор
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
<div className="text-xs text-gray-500">
|
||||||
Без модерации
|
Без модерации
|
||||||
</div>
|
</div>
|
||||||
|
{gameProposalMode === 'organizer_only' && (
|
||||||
|
<div className="absolute top-3 right-3">
|
||||||
|
<Sparkles className="w-4 h-4 text-accent-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 pt-4">
|
{/* Actions */}
|
||||||
<Button
|
<div className="flex gap-3 pt-4 border-t border-dark-600">
|
||||||
|
<NeonButton
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="outline"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
onClick={() => navigate('/marathons')}
|
onClick={() => navigate('/marathons')}
|
||||||
>
|
>
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</NeonButton>
|
||||||
<Button type="submit" className="flex-1" isLoading={isLoading}>
|
<NeonButton
|
||||||
|
type="submit"
|
||||||
|
className="flex-1"
|
||||||
|
isLoading={isLoading}
|
||||||
|
icon={<Sparkles className="w-4 h-4" />}
|
||||||
|
>
|
||||||
Создать
|
Создать
|
||||||
</Button>
|
</NeonButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</GlassCard>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,113 +1,251 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { Button } from '@/components/ui'
|
import { NeonButton, GradientButton, FeatureCard } from '@/components/ui'
|
||||||
import { Gamepad2, Users, Trophy, Sparkles } from 'lucide-react'
|
import { Gamepad2, Users, Trophy, Sparkles, Zap, Target, Crown, ArrowRight } from 'lucide-react'
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto text-center">
|
<div className="-mt-8 relative">
|
||||||
{/* Hero */}
|
{/* Global animated background - covers entire page */}
|
||||||
<div className="py-12">
|
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||||
<div className="flex justify-center mb-6">
|
{/* Gradient orbs */}
|
||||||
<Gamepad2 className="w-20 h-20 text-primary-500" />
|
<div className="absolute top-[10%] left-[10%] w-[500px] h-[500px] bg-neon-500/20 rounded-full blur-[120px] animate-float" />
|
||||||
|
<div className="absolute top-[40%] right-[10%] w-[600px] h-[600px] bg-accent-500/20 rounded-full blur-[120px] animate-float" style={{ animationDelay: '-3s' }} />
|
||||||
|
<div className="absolute top-[60%] left-[30%] w-[700px] h-[700px] bg-pink-500/10 rounded-full blur-[150px]" />
|
||||||
|
<div className="absolute bottom-[10%] right-[30%] w-[400px] h-[400px] bg-neon-500/15 rounded-full blur-[100px] animate-float" style={{ animationDelay: '-1.5s' }} />
|
||||||
|
<div className="absolute bottom-[30%] left-[5%] w-[450px] h-[450px] bg-accent-500/15 rounded-full blur-[100px] animate-float" style={{ animationDelay: '-4.5s' }} />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
|
|
||||||
Игровой Марафон
|
{/* Hero Section */}
|
||||||
|
<section className="relative min-h-[80vh] flex items-center justify-center overflow-hidden">
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative z-10 max-w-5xl mx-auto text-center px-4">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex justify-center mb-8">
|
||||||
|
<div className="relative">
|
||||||
|
<Gamepad2 className="w-24 h-24 text-neon-500 animate-float drop-shadow-[0_0_20px_rgba(34,211,238,0.4)]" />
|
||||||
|
<div className="absolute inset-0 bg-neon-500/20 blur-2xl rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title with glitch effect */}
|
||||||
|
<h1 className="relative mb-6">
|
||||||
|
<span className="block text-5xl md:text-7xl font-bold font-display tracking-wider text-white">
|
||||||
|
ИГРОВОЙ
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="glitch block text-5xl md:text-7xl font-bold font-display tracking-wider text-neon-500 neon-text"
|
||||||
|
data-text="МАРАФОН"
|
||||||
|
>
|
||||||
|
МАРАФОН
|
||||||
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-gray-400 mb-8 max-w-2xl mx-auto">
|
|
||||||
Соревнуйтесь с друзьями в игровых челленджах. Крутите колесо, выполняйте задания, зарабатывайте очки и станьте чемпионом!
|
{/* Subtitle with typing effect */}
|
||||||
|
<p className="text-xl md:text-2xl text-gray-300 mb-10 max-w-2xl mx-auto leading-relaxed">
|
||||||
|
Соревнуйтесь с друзьями в{' '}
|
||||||
|
<span className="text-neon-400">игровых челленджах</span>.
|
||||||
|
<br className="hidden md:block" />
|
||||||
|
Крутите колесо, выполняйте задания, станьте{' '}
|
||||||
|
<span className="text-accent-400">чемпионом</span>!
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex gap-4 justify-center">
|
{/* CTA Buttons */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<Link to="/marathons">
|
<Link to="/marathons">
|
||||||
<Button size="lg">К марафонам</Button>
|
<GradientButton size="lg" icon={<ArrowRight className="w-5 h-5" />}>
|
||||||
|
К марафонам
|
||||||
|
</GradientButton>
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Link to="/register">
|
<Link to="/register">
|
||||||
<Button size="lg">Начать</Button>
|
<GradientButton size="lg" icon={<Zap className="w-5 h-5" />}>
|
||||||
|
Начать играть
|
||||||
|
</GradientButton>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/login">
|
<Link to="/login">
|
||||||
<Button size="lg" variant="secondary">Войти</Button>
|
<NeonButton size="lg" variant="outline" color="neon">
|
||||||
|
Войти
|
||||||
|
</NeonButton>
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Features */}
|
{/* Scroll indicator */}
|
||||||
<div className="grid md:grid-cols-3 gap-8 py-12">
|
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce">
|
||||||
<div className="card text-center">
|
<div className="w-6 h-10 border-2 border-gray-600 rounded-full flex justify-center pt-2">
|
||||||
<div className="flex justify-center mb-4">
|
<div className="w-1 h-2 bg-neon-500 rounded-full animate-pulse" />
|
||||||
<Sparkles className="w-12 h-12 text-yellow-500" />
|
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-white mb-2">Случайные челленджи</h3>
|
</div>
|
||||||
<p className="text-gray-400">
|
</section>
|
||||||
Крутите колесо, чтобы получить случайную игру и задание. Проверьте свои навыки неожиданным способом!
|
|
||||||
|
{/* Features Section */}
|
||||||
|
<section className="py-24 relative">
|
||||||
|
<div className="max-w-6xl mx-auto px-4">
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||||
|
Почему <span className="gradient-neon-text">Игровой Марафон</span>?
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-400 max-w-2xl mx-auto">
|
||||||
|
Уникальный способ играть с друзьями. Случайные челленджи, честная конкуренция, незабываемые моменты.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card text-center">
|
<div className="grid md:grid-cols-3 gap-6 stagger-children">
|
||||||
<div className="flex justify-center mb-4">
|
<FeatureCard
|
||||||
<Users className="w-12 h-12 text-green-500" />
|
icon={<Sparkles className="w-7 h-7" />}
|
||||||
</div>
|
title="Случайные челленджи"
|
||||||
<h3 className="text-xl font-bold text-white mb-2">Играйте с друзьями</h3>
|
description="Крутите колесо и получайте уникальные задания. ИИ генерирует челленджи специально под ваши игры."
|
||||||
<p className="text-gray-400">
|
color="neon"
|
||||||
Создавайте приватные марафоны и приглашайте друзей. Каждый добавляет свои любимые игры.
|
/>
|
||||||
</p>
|
<FeatureCard
|
||||||
</div>
|
icon={<Users className="w-7 h-7" />}
|
||||||
|
title="Играйте с друзьями"
|
||||||
<div className="card text-center">
|
description="Создавайте приватные марафоны. Каждый добавляет свои игры, все соревнуются на равных."
|
||||||
<div className="flex justify-center mb-4">
|
color="purple"
|
||||||
<Trophy className="w-12 h-12 text-primary-500" />
|
/>
|
||||||
</div>
|
<FeatureCard
|
||||||
<h3 className="text-xl font-bold text-white mb-2">Соревнуйтесь за очки</h3>
|
icon={<Trophy className="w-7 h-7" />}
|
||||||
<p className="text-gray-400">
|
title="Зарабатывайте очки"
|
||||||
Выполняйте задания, чтобы зарабатывать очки. Собирайте серии для бонусных множителей!
|
description="Выполняйте задания, собирайте серии побед. Бонусные множители за стрики!"
|
||||||
</p>
|
color="pink"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* How it works */}
|
{/* How it works */}
|
||||||
<div className="py-12">
|
<section className="py-24 relative">
|
||||||
<h2 className="text-2xl font-bold text-white mb-8">Как это работает</h2>
|
<div className="max-w-6xl mx-auto px-4 relative z-10">
|
||||||
<div className="grid md:grid-cols-4 gap-6 text-left">
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||||
|
Как это работает
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
Четыре простых шага до победы
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">1</div>
|
{/* Connection line */}
|
||||||
<div className="relative z-10 pt-6">
|
<div className="hidden md:block absolute top-12 left-0 right-0 h-0.5 bg-gradient-to-r from-neon-500 via-accent-500 to-pink-500" />
|
||||||
<h4 className="font-bold text-white mb-2">Создайте марафон</h4>
|
|
||||||
<p className="text-gray-400 text-sm">Начните новый марафон и пригласите друзей по уникальному коду</p>
|
<div className="grid md:grid-cols-4 gap-8">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
step: 1,
|
||||||
|
icon: <Gamepad2 className="w-6 h-6" />,
|
||||||
|
title: 'Создайте марафон',
|
||||||
|
desc: 'Начните новый марафон и пригласите друзей по коду',
|
||||||
|
color: 'neon',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 2,
|
||||||
|
icon: <Target className="w-6 h-6" />,
|
||||||
|
title: 'Добавьте игры',
|
||||||
|
desc: 'Каждый добавляет игры. ИИ генерирует задания',
|
||||||
|
color: 'neon',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 3,
|
||||||
|
icon: <Zap className="w-6 h-6" />,
|
||||||
|
title: 'Крутите и играйте',
|
||||||
|
desc: 'Крутите колесо, выполняйте задания',
|
||||||
|
color: 'accent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: 4,
|
||||||
|
icon: <Crown className="w-6 h-6" />,
|
||||||
|
title: 'Победите!',
|
||||||
|
desc: 'Зарабатывайте очки и станьте чемпионом',
|
||||||
|
color: 'pink',
|
||||||
|
},
|
||||||
|
].map((item, index) => (
|
||||||
|
<div key={item.step} className="relative text-center group">
|
||||||
|
{/* Step circle */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
relative z-10 w-24 h-24 mx-auto mb-6 rounded-2xl
|
||||||
|
bg-dark-800 border-2 transition-all duration-300
|
||||||
|
flex items-center justify-center
|
||||||
|
group-hover:-translate-y-2
|
||||||
|
${item.color === 'neon' ? 'border-neon-500/50 group-hover:border-neon-500 group-hover:shadow-[0_0_20px_rgba(34,211,238,0.25)]' : ''}
|
||||||
|
${item.color === 'accent' ? 'border-accent-500/50 group-hover:border-accent-500 group-hover:shadow-[0_0_20px_rgba(139,92,246,0.25)]' : ''}
|
||||||
|
${item.color === 'pink' ? 'border-pink-500/50 group-hover:border-pink-500 group-hover:shadow-[0_0_20px_rgba(244,114,182,0.25)]' : ''}
|
||||||
|
`}
|
||||||
|
style={{ animationDelay: `${index * 100}ms` }}
|
||||||
|
>
|
||||||
|
<div className={`
|
||||||
|
${item.color === 'neon' ? 'text-neon-500' : ''}
|
||||||
|
${item.color === 'accent' ? 'text-accent-500' : ''}
|
||||||
|
${item.color === 'pink' ? 'text-pink-500' : ''}
|
||||||
|
`}>
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
<div className={`
|
||||||
|
absolute -top-2 -right-2 w-8 h-8 rounded-full
|
||||||
|
flex items-center justify-center text-sm font-bold
|
||||||
|
${item.color === 'neon' ? 'bg-neon-500 text-dark-900' : ''}
|
||||||
|
${item.color === 'accent' ? 'bg-accent-500 text-white' : ''}
|
||||||
|
${item.color === 'pink' ? 'bg-pink-500 text-white' : ''}
|
||||||
|
`}>
|
||||||
|
{item.step}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<h4 className="text-lg font-semibold text-white mb-2">
|
||||||
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">2</div>
|
{item.title}
|
||||||
<div className="relative z-10 pt-6">
|
</h4>
|
||||||
<h4 className="font-bold text-white mb-2">Добавьте игры</h4>
|
<p className="text-gray-400 text-sm">
|
||||||
<p className="text-gray-400 text-sm">Все добавляют игры, в которые хотят играть. ИИ генерирует задания</p>
|
{item.desc}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="relative">
|
{/* CTA Section */}
|
||||||
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">3</div>
|
<section className="py-24 relative">
|
||||||
<div className="relative z-10 pt-6">
|
<div className="max-w-4xl mx-auto px-4 text-center">
|
||||||
<h4 className="font-bold text-white mb-2">Крутите и играйте</h4>
|
<div className="glass-neon rounded-2xl p-12 relative overflow-hidden">
|
||||||
<p className="text-gray-400 text-sm">Крутите колесо, получите задание, выполните его и отправьте доказательство</p>
|
{/* Background glow */}
|
||||||
</div>
|
<div className="absolute inset-0 bg-gradient-to-r from-neon-500/5 via-accent-500/5 to-pink-500/5" />
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative z-10">
|
||||||
<div className="text-5xl font-bold text-primary-500/20 absolute -top-4 -left-2">4</div>
|
<h2 className="text-3xl md:text-4xl font-bold text-white mb-4">
|
||||||
<div className="relative z-10 pt-6">
|
Готовы к соревнованиям?
|
||||||
<h4 className="font-bold text-white mb-2">Победите!</h4>
|
</h2>
|
||||||
<p className="text-gray-400 text-sm">Зарабатывайте очки, поднимайтесь в таблице лидеров, станьте чемпионом!</p>
|
<p className="text-gray-300 mb-8 max-w-xl mx-auto">
|
||||||
</div>
|
Создавайте марафоны, приглашайте друзей и соревнуйтесь в игровых челленджах
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<Link to="/marathons">
|
||||||
|
<GradientButton size="lg" icon={<ArrowRight className="w-5 h-5" />}>
|
||||||
|
Перейти к марафонам
|
||||||
|
</GradientButton>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link to="/register">
|
||||||
|
<GradientButton size="lg" icon={<Zap className="w-5 h-5" />}>
|
||||||
|
Создать аккаунт бесплатно
|
||||||
|
</GradientButton>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { useParams, useNavigate, Link } from 'react-router-dom'
|
|||||||
import { marathonsApi } from '@/api'
|
import { marathonsApi } from '@/api'
|
||||||
import type { MarathonPublicInfo } from '@/types'
|
import type { MarathonPublicInfo } from '@/types'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { Button, Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
|
import { NeonButton, GlassCard } from '@/components/ui'
|
||||||
import { Users, Loader2, Trophy, UserPlus, LogIn } from 'lucide-react'
|
import { Users, Loader2, Trophy, UserPlus, LogIn, Gamepad2, AlertCircle, Sparkles, Crown } from 'lucide-react'
|
||||||
|
|
||||||
export function InvitePage() {
|
export function InvitePage() {
|
||||||
const { code } = useParams<{ code: string }>()
|
const { code } = useParams<{ code: string }>()
|
||||||
@@ -63,8 +63,9 @@ export function InvitePage() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-24">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
|
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
|
||||||
|
<p className="text-gray-400">Загрузка приглашения...</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -72,97 +73,154 @@ export function InvitePage() {
|
|||||||
if (error || !marathon) {
|
if (error || !marathon) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto">
|
<div className="max-w-md mx-auto">
|
||||||
<Card>
|
<GlassCard className="text-center py-12">
|
||||||
<CardContent className="text-center py-8">
|
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-red-500/10 border border-red-500/30 flex items-center justify-center">
|
||||||
<div className="text-red-400 mb-4">{error || 'Марафон не найден'}</div>
|
<AlertCircle className="w-8 h-8 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-white mb-2">Ошибка</h2>
|
||||||
|
<p className="text-gray-400 mb-6">{error || 'Марафон не найден'}</p>
|
||||||
<Link to="/marathons">
|
<Link to="/marathons">
|
||||||
<Button variant="secondary">К списку марафонов</Button>
|
<NeonButton variant="outline">К списку марафонов</NeonButton>
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
</GlassCard>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusText = {
|
const getStatusConfig = (status: string) => {
|
||||||
preparing: 'Подготовка',
|
switch (status) {
|
||||||
active: 'Активен',
|
case 'preparing':
|
||||||
finished: 'Завершён',
|
return {
|
||||||
}[marathon.status]
|
color: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||||
|
text: 'Подготовка',
|
||||||
|
dot: 'bg-yellow-500',
|
||||||
|
}
|
||||||
|
case 'active':
|
||||||
|
return {
|
||||||
|
color: 'bg-neon-500/20 text-neon-400 border-neon-500/30',
|
||||||
|
text: 'Активен',
|
||||||
|
dot: 'bg-neon-500 animate-pulse',
|
||||||
|
}
|
||||||
|
case 'finished':
|
||||||
|
return {
|
||||||
|
color: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||||
|
text: 'Завершён',
|
||||||
|
dot: 'bg-gray-500',
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
color: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||||
|
text: status,
|
||||||
|
dot: 'bg-gray-500',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = getStatusConfig(marathon.status)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto">
|
<div className="min-h-[70vh] flex items-center justify-center px-4">
|
||||||
<Card>
|
{/* Background effects */}
|
||||||
<CardHeader className="text-center">
|
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||||
<CardTitle className="flex items-center justify-center gap-2">
|
<div className="absolute top-1/4 -left-32 w-96 h-96 bg-neon-500/10 rounded-full blur-[100px]" />
|
||||||
<Trophy className="w-6 h-6 text-primary-500" />
|
<div className="absolute bottom-1/4 -right-32 w-96 h-96 bg-accent-500/10 rounded-full blur-[100px]" />
|
||||||
Приглашение в марафон
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{/* Marathon info */}
|
|
||||||
<div className="text-center">
|
|
||||||
<h2 className="text-2xl font-bold text-white mb-2">{marathon.title}</h2>
|
|
||||||
{marathon.description && (
|
|
||||||
<p className="text-gray-400 text-sm mb-4">{marathon.description}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-center gap-4 text-sm text-gray-400">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Users className="w-4 h-4" />
|
|
||||||
{marathon.participants_count} участников
|
|
||||||
</span>
|
|
||||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
|
||||||
marathon.status === 'active' ? 'bg-green-900/50 text-green-400' :
|
|
||||||
marathon.status === 'preparing' ? 'bg-yellow-900/50 text-yellow-400' :
|
|
||||||
'bg-gray-700 text-gray-400'
|
|
||||||
}`}>
|
|
||||||
{statusText}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-500 text-xs mt-2">
|
|
||||||
Организатор: {marathon.creator_nickname}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-md">
|
||||||
|
<GlassCard variant="neon" className="animate-scale-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 border border-neon-500/30 flex items-center justify-center">
|
||||||
|
<Trophy className="w-10 h-10 text-neon-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold text-white mb-1">Приглашение в марафон</h1>
|
||||||
|
<p className="text-gray-400 text-sm">Вас пригласили присоединиться</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Marathon info */}
|
||||||
|
<div className="glass rounded-xl p-5 mb-6">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center border border-neon-500/20 flex-shrink-0">
|
||||||
|
<Gamepad2 className="w-7 h-7 text-neon-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h2 className="text-xl font-bold text-white mb-1 truncate">{marathon.title}</h2>
|
||||||
|
{marathon.description && (
|
||||||
|
<p className="text-gray-400 text-sm line-clamp-2 mb-3">{marathon.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className={`px-2.5 py-1 rounded-full text-xs font-medium border flex items-center gap-1.5 ${status.color}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${status.dot}`} />
|
||||||
|
{status.text}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5 text-sm text-gray-400">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
{marathon.participants_count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Organizer */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-dark-600 flex items-center gap-2 text-sm text-gray-500">
|
||||||
|
<Crown className="w-4 h-4 text-yellow-500" />
|
||||||
|
<span>Организатор:</span>
|
||||||
|
<span className="text-gray-300">{marathon.creator_nickname}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
{marathon.status === 'finished' ? (
|
{marathon.status === 'finished' ? (
|
||||||
<div className="text-center text-gray-400">
|
<div className="text-center">
|
||||||
Этот марафон уже завершён
|
<div className="w-12 h-12 mx-auto mb-3 rounded-xl bg-gray-500/10 flex items-center justify-center">
|
||||||
|
<AlertCircle className="w-6 h-6 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 mb-4">Этот марафон уже завершён</p>
|
||||||
|
<Link to="/marathons">
|
||||||
|
<NeonButton variant="outline" className="w-full">
|
||||||
|
К списку марафонов
|
||||||
|
</NeonButton>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : isAuthenticated ? (
|
) : isAuthenticated ? (
|
||||||
/* Authenticated - show join button */
|
<NeonButton
|
||||||
<Button
|
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
onClick={handleJoin}
|
onClick={handleJoin}
|
||||||
isLoading={isJoining}
|
isLoading={isJoining}
|
||||||
|
icon={<Sparkles className="w-5 h-5" />}
|
||||||
>
|
>
|
||||||
<UserPlus className="w-4 h-4 mr-2" />
|
Присоединиться
|
||||||
Присоединиться к марафону
|
</NeonButton>
|
||||||
</Button>
|
|
||||||
) : (
|
) : (
|
||||||
/* Not authenticated - show login/register options */
|
<div className="space-y-4">
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-center text-gray-400 text-sm">
|
<p className="text-center text-gray-400 text-sm">
|
||||||
Чтобы присоединиться, войдите или зарегистрируйтесь
|
Чтобы присоединиться, войдите или зарегистрируйтесь
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<NeonButton
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
onClick={() => handleAuthRedirect('/login')}
|
onClick={() => handleAuthRedirect('/login')}
|
||||||
|
icon={<LogIn className="w-5 h-5" />}
|
||||||
>
|
>
|
||||||
<LogIn className="w-4 h-4 mr-2" />
|
|
||||||
Войти
|
Войти
|
||||||
</Button>
|
</NeonButton>
|
||||||
<Button
|
<NeonButton
|
||||||
variant="secondary"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => handleAuthRedirect('/register')}
|
onClick={() => handleAuthRedirect('/register')}
|
||||||
|
icon={<UserPlus className="w-5 h-5" />}
|
||||||
>
|
>
|
||||||
<UserPlus className="w-4 h-4 mr-2" />
|
|
||||||
Зарегистрироваться
|
Зарегистрироваться
|
||||||
</Button>
|
</NeonButton>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</GlassCard>
|
||||||
</Card>
|
|
||||||
|
{/* Decorative elements */}
|
||||||
|
<div className="absolute -top-4 -left-4 w-24 h-24 border border-neon-500/20 rounded-2xl -z-10" />
|
||||||
|
<div className="absolute -bottom-4 -right-4 w-32 h-32 border border-accent-500/20 rounded-2xl -z-10" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { useState, useEffect } from 'react'
|
|||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { marathonsApi } from '@/api'
|
import { marathonsApi } from '@/api'
|
||||||
import type { LeaderboardEntry } from '@/types'
|
import type { LeaderboardEntry } from '@/types'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
|
import { GlassCard } from '@/components/ui'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { Trophy, Flame, ArrowLeft, Loader2 } from 'lucide-react'
|
import { Trophy, Flame, ArrowLeft, Loader2, Crown, Medal, Award, Star, Zap, Target } from 'lucide-react'
|
||||||
|
|
||||||
export function LeaderboardPage() {
|
export function LeaderboardPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
@@ -28,92 +28,257 @@ export function LeaderboardPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getRankIcon = (rank: number) => {
|
const getRankConfig = (rank: number) => {
|
||||||
switch (rank) {
|
switch (rank) {
|
||||||
case 1:
|
case 1:
|
||||||
return <Trophy className="w-6 h-6 text-yellow-500" />
|
return {
|
||||||
|
icon: <Crown className="w-6 h-6" />,
|
||||||
|
color: 'text-yellow-400',
|
||||||
|
bg: 'bg-yellow-500/20',
|
||||||
|
border: 'border-yellow-500/30',
|
||||||
|
glow: 'shadow-[0_0_20px_rgba(234,179,8,0.3)]',
|
||||||
|
gradient: 'from-yellow-500/20 via-transparent to-transparent',
|
||||||
|
}
|
||||||
case 2:
|
case 2:
|
||||||
return <Trophy className="w-6 h-6 text-gray-400" />
|
return {
|
||||||
|
icon: <Medal className="w-6 h-6" />,
|
||||||
|
color: 'text-gray-300',
|
||||||
|
bg: 'bg-gray-400/20',
|
||||||
|
border: 'border-gray-400/30',
|
||||||
|
glow: 'shadow-[0_0_15px_rgba(156,163,175,0.2)]',
|
||||||
|
gradient: 'from-gray-400/10 via-transparent to-transparent',
|
||||||
|
}
|
||||||
case 3:
|
case 3:
|
||||||
return <Trophy className="w-6 h-6 text-amber-700" />
|
return {
|
||||||
|
icon: <Award className="w-6 h-6" />,
|
||||||
|
color: 'text-amber-600',
|
||||||
|
bg: 'bg-amber-600/20',
|
||||||
|
border: 'border-amber-600/30',
|
||||||
|
glow: 'shadow-[0_0_15px_rgba(217,119,6,0.2)]',
|
||||||
|
gradient: 'from-amber-600/10 via-transparent to-transparent',
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return <span className="text-gray-500 font-mono w-6 text-center">{rank}</span>
|
return {
|
||||||
|
icon: <span className="text-gray-500 font-mono font-bold">{rank}</span>,
|
||||||
|
color: 'text-gray-500',
|
||||||
|
bg: 'bg-dark-700',
|
||||||
|
border: 'border-dark-600',
|
||||||
|
glow: '',
|
||||||
|
gradient: '',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Top 3 for podium
|
||||||
|
const topThree = leaderboard.slice(0, 3)
|
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const totalPoints = leaderboard.reduce((acc, e) => acc + e.total_points, 0)
|
||||||
|
const maxStreak = Math.max(...leaderboard.map(e => e.current_streak), 0)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-24">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
|
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
|
||||||
|
<p className="text-gray-400">Загрузка рейтинга...</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4 mb-8">
|
<div className="flex items-center gap-4 mb-8">
|
||||||
<Link to={`/marathons/${id}`} className="text-gray-400 hover:text-white">
|
<Link
|
||||||
<ArrowLeft className="w-6 h-6" />
|
to={`/marathons/${id}`}
|
||||||
|
className="w-10 h-10 rounded-xl bg-dark-700 border border-dark-600 flex items-center justify-center text-gray-400 hover:text-white hover:border-neon-500/30 transition-all"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
</Link>
|
</Link>
|
||||||
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white">Таблица лидеров</h1>
|
<h1 className="text-2xl font-bold text-white">Таблица лидеров</h1>
|
||||||
|
<p className="text-gray-400 text-sm">Рейтинг участников марафона</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Trophy className="w-5 h-5 text-yellow-500" />
|
|
||||||
Рейтинг
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{leaderboard.length === 0 ? (
|
{leaderboard.length === 0 ? (
|
||||||
<p className="text-center text-gray-400 py-8">Пока нет участников</p>
|
<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">
|
||||||
|
<Trophy 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">Станьте первым в рейтинге!</p>
|
||||||
|
</GlassCard>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Podium for top 3 */}
|
||||||
|
{topThree.length >= 3 && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-end justify-center gap-4 mb-4">
|
||||||
|
{/* 2nd place */}
|
||||||
|
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '100ms' }}>
|
||||||
|
<div className={`
|
||||||
|
w-20 h-20 rounded-2xl mb-3 flex items-center justify-center
|
||||||
|
bg-gray-400/10 border border-gray-400/30
|
||||||
|
shadow-[0_0_20px_rgba(156,163,175,0.2)]
|
||||||
|
`}>
|
||||||
|
<span className="text-3xl font-bold text-gray-300">2</span>
|
||||||
|
</div>
|
||||||
|
<Link to={`/users/${topThree[1].user.id}`} className="glass rounded-xl p-4 text-center w-28 hover:border-neon-500/30 transition-colors border border-transparent">
|
||||||
|
<Medal className="w-6 h-6 text-gray-300 mx-auto mb-2" />
|
||||||
|
<p className="text-sm font-medium text-white truncate hover:text-neon-400 transition-colors">{topThree[1].user.nickname}</p>
|
||||||
|
<p className="text-xs text-gray-400">{topThree[1].total_points} очков</p>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 1st place */}
|
||||||
|
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '0ms' }}>
|
||||||
|
<div className={`
|
||||||
|
w-24 h-24 rounded-2xl mb-3 flex items-center justify-center
|
||||||
|
bg-yellow-500/20 border border-yellow-500/30
|
||||||
|
shadow-[0_0_30px_rgba(234,179,8,0.4)]
|
||||||
|
`}>
|
||||||
|
<Crown className="w-10 h-10 text-yellow-400" />
|
||||||
|
</div>
|
||||||
|
<Link to={`/users/${topThree[0].user.id}`} className="glass-neon rounded-xl p-4 text-center w-32 hover:shadow-[0_0_20px_rgba(34,211,238,0.3)] transition-shadow">
|
||||||
|
<Star className="w-6 h-6 text-yellow-400 mx-auto mb-2" />
|
||||||
|
<p className="font-semibold text-white truncate hover:text-neon-400 transition-colors">{topThree[0].user.nickname}</p>
|
||||||
|
<p className="text-sm text-neon-400 font-bold">{topThree[0].total_points} очков</p>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3rd place */}
|
||||||
|
<div className="flex flex-col items-center animate-slide-in-up" style={{ animationDelay: '200ms' }}>
|
||||||
|
<div className={`
|
||||||
|
w-20 h-20 rounded-2xl mb-3 flex items-center justify-center
|
||||||
|
bg-amber-600/10 border border-amber-600/30
|
||||||
|
shadow-[0_0_20px_rgba(217,119,6,0.2)]
|
||||||
|
`}>
|
||||||
|
<span className="text-3xl font-bold text-amber-600">3</span>
|
||||||
|
</div>
|
||||||
|
<Link to={`/users/${topThree[2].user.id}`} className="glass rounded-xl p-4 text-center w-28 hover:border-neon-500/30 transition-colors border border-transparent">
|
||||||
|
<Award className="w-6 h-6 text-amber-600 mx-auto mb-2" />
|
||||||
|
<p className="text-sm font-medium text-white truncate hover:text-neon-400 transition-colors">{topThree[2].user.nickname}</p>
|
||||||
|
<p className="text-xs text-gray-400">{topThree[2].total_points} очков</p>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||||
|
<div className="glass rounded-xl p-4 text-center">
|
||||||
|
<Trophy className="w-6 h-6 text-neon-400 mx-auto mb-2" />
|
||||||
|
<p className="text-2xl font-bold text-white">{leaderboard.length}</p>
|
||||||
|
<p className="text-xs text-gray-400">Участников</p>
|
||||||
|
</div>
|
||||||
|
<div className="glass rounded-xl p-4 text-center">
|
||||||
|
<Zap className="w-6 h-6 text-accent-400 mx-auto mb-2" />
|
||||||
|
<p className="text-2xl font-bold text-white">{totalPoints}</p>
|
||||||
|
<p className="text-xs text-gray-400">Всего очков</p>
|
||||||
|
</div>
|
||||||
|
<div className="glass rounded-xl p-4 text-center">
|
||||||
|
<Flame className="w-6 h-6 text-orange-400 mx-auto mb-2" />
|
||||||
|
<p className="text-2xl font-bold text-white">{maxStreak}</p>
|
||||||
|
<p className="text-xs text-gray-400">Макс. серия</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full leaderboard */}
|
||||||
|
<GlassCard>
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-neon-500/20 flex items-center justify-center">
|
||||||
|
<Target className="w-5 h-5 text-neon-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">Полный рейтинг</h3>
|
||||||
|
<p className="text-sm text-gray-400">Все участники марафона</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{leaderboard.map((entry) => (
|
{leaderboard.map((entry, index) => {
|
||||||
|
const isCurrentUser = entry.user.id === user?.id
|
||||||
|
const rankConfig = getRankConfig(entry.rank)
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={entry.user.id}
|
key={entry.user.id}
|
||||||
className={`flex items-center gap-4 p-4 rounded-lg ${
|
className={`
|
||||||
entry.user.id === user?.id
|
relative flex items-center gap-4 p-4 rounded-xl
|
||||||
? 'bg-primary-500/20 border border-primary-500/50'
|
transition-all duration-300 group
|
||||||
: 'bg-gray-900'
|
${isCurrentUser
|
||||||
}`}
|
? 'bg-neon-500/10 border border-neon-500/30 shadow-[0_0_14px_rgba(34,211,238,0.08)]'
|
||||||
|
: `${rankConfig.bg} border ${rankConfig.border} hover:border-neon-500/20`
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
style={{ animationDelay: `${index * 50}ms` }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center w-8">
|
{/* Gradient overlay for top 3 */}
|
||||||
{getRankIcon(entry.rank)}
|
{entry.rank <= 3 && (
|
||||||
|
<div className={`absolute inset-0 bg-gradient-to-r ${rankConfig.gradient} rounded-xl pointer-events-none`} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rank */}
|
||||||
|
<div className={`
|
||||||
|
relative w-10 h-10 rounded-xl flex items-center justify-center
|
||||||
|
${rankConfig.bg} ${rankConfig.color} ${rankConfig.glow}
|
||||||
|
`}>
|
||||||
|
{rankConfig.icon}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
{/* User info */}
|
||||||
<div className="font-medium text-white">
|
<div className="relative flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
to={`/users/${entry.user.id}`}
|
||||||
|
className={`font-semibold truncate hover:text-neon-400 transition-colors ${isCurrentUser ? 'text-neon-400' : 'text-white'}`}
|
||||||
|
>
|
||||||
{entry.user.nickname}
|
{entry.user.nickname}
|
||||||
{entry.user.id === user?.id && (
|
</Link>
|
||||||
<span className="ml-2 text-xs text-primary-400">(Вы)</span>
|
{isCurrentUser && (
|
||||||
|
<span className="px-2 py-0.5 text-xs font-medium bg-neon-500/20 text-neon-400 rounded-full border border-neon-500/30">
|
||||||
|
Вы
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-400">
|
<div className="flex items-center gap-3 text-sm text-gray-400">
|
||||||
{entry.completed_count} выполнено, {entry.dropped_count} пропущено
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="w-1.5 h-1.5 bg-green-500 rounded-full" />
|
||||||
|
{entry.completed_count} выполнено
|
||||||
|
</span>
|
||||||
|
{entry.dropped_count > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="w-1.5 h-1.5 bg-red-500 rounded-full" />
|
||||||
|
{entry.dropped_count} пропущено
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Streak */}
|
||||||
{entry.current_streak > 0 && (
|
{entry.current_streak > 0 && (
|
||||||
<div className="flex items-center gap-1 text-yellow-500">
|
<div className="relative flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-orange-500/10 border border-orange-500/20">
|
||||||
<Flame className="w-4 h-4" />
|
<Flame className="w-4 h-4 text-orange-400" />
|
||||||
<span className="text-sm">{entry.current_streak}</span>
|
<span className="text-sm font-semibold text-orange-400">{entry.current_streak}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="text-right">
|
{/* Points */}
|
||||||
<div className="text-xl font-bold text-primary-400">
|
<div className="relative text-right">
|
||||||
|
<div className={`text-xl font-bold ${isCurrentUser ? 'text-neon-400' : 'text-white'}`}>
|
||||||
{entry.total_points}
|
{entry.total_points}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500">очков</div>
|
<div className="text-xs text-gray-500">очков</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,8 @@ import { zodResolver } from '@hookform/resolvers/zod'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { marathonsApi } from '@/api'
|
import { marathonsApi } from '@/api'
|
||||||
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui'
|
import { NeonButton, Input, GlassCard } from '@/components/ui'
|
||||||
|
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 символов'),
|
||||||
@@ -16,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,
|
||||||
@@ -31,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()
|
||||||
@@ -51,17 +58,154 @@ 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 = [
|
||||||
|
{ icon: <Trophy className="w-5 h-5" />, text: 'Соревнуйтесь с друзьями' },
|
||||||
|
{ icon: <Target className="w-5 h-5" />, text: 'Выполняйте челленджи' },
|
||||||
|
{ icon: <Zap className="w-5 h-5" />, text: 'Зарабатывайте очки' },
|
||||||
|
{ icon: <Users className="w-5 h-5" />, text: 'Создавайте марафоны' },
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto">
|
<div className="min-h-[80vh] flex items-center justify-center px-4 -mt-8">
|
||||||
<Card>
|
{/* Background effects */}
|
||||||
<CardHeader>
|
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||||
<CardTitle className="text-center">Вход</CardTitle>
|
<div className="absolute top-1/4 -left-32 w-96 h-96 bg-neon-500/10 rounded-full blur-[100px]" />
|
||||||
</CardHeader>
|
<div className="absolute bottom-1/4 -right-32 w-96 h-96 bg-accent-500/10 rounded-full blur-[100px]" />
|
||||||
<CardContent>
|
</div>
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
|
{/* Bento Grid */}
|
||||||
|
<div className="relative w-full max-w-4xl">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-scale-in">
|
||||||
|
{/* Branding Block (left) */}
|
||||||
|
<GlassCard className="p-8 flex flex-col justify-center relative overflow-hidden" variant="neon">
|
||||||
|
{/* Background decoration */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<div className="absolute -top-20 -left-20 w-48 h-48 bg-neon-500/20 rounded-full blur-[60px]" />
|
||||||
|
<div className="absolute -bottom-20 -right-20 w-48 h-48 bg-accent-500/20 rounded-full blur-[60px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex justify-center md:justify-start mb-6">
|
||||||
|
<div className="w-20 h-20 rounded-2xl bg-neon-500/10 border border-neon-500/30 flex items-center justify-center shadow-[0_0_24px_rgba(34,211,238,0.25)]">
|
||||||
|
<Gamepad2 className="w-10 h-10 text-neon-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold text-white mb-2 text-center md:text-left">
|
||||||
|
Game Marathon
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mb-8 text-center md:text-left">
|
||||||
|
Платформа для игровых соревнований
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-2 p-3 rounded-xl bg-dark-700/50 border border-dark-600"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-neon-500/20 flex items-center justify-center text-neon-400">
|
||||||
|
{feature.icon}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-300">{feature.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
{/* Form Block (right) */}
|
||||||
|
<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) && (
|
{(submitError || error) && (
|
||||||
<div className="p-3 bg-red-500/20 border border-red-500 rounded-lg text-red-500 text-sm">
|
<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">
|
||||||
{submitError || error}
|
<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 */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-2">Добро пожаловать!</h2>
|
||||||
|
<p className="text-gray-400">Войдите, чтобы продолжить</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||||
|
{(submitError || error) && (
|
||||||
|
<div className="flex items-center gap-3 p-4 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm animate-shake">
|
||||||
|
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span>{submitError || error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -69,6 +213,7 @@ export function LoginPage() {
|
|||||||
label="Логин"
|
label="Логин"
|
||||||
placeholder="Введите логин"
|
placeholder="Введите логин"
|
||||||
error={errors.login?.message}
|
error={errors.login?.message}
|
||||||
|
autoComplete="username"
|
||||||
{...register('login')}
|
{...register('login')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -77,22 +222,42 @@ export function LoginPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
placeholder="Введите пароль"
|
placeholder="Введите пароль"
|
||||||
error={errors.password?.message}
|
error={errors.password?.message}
|
||||||
|
autoComplete="current-password"
|
||||||
{...register('password')}
|
{...register('password')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" isLoading={isLoading}>
|
<NeonButton
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
isLoading={isLoading}
|
||||||
|
icon={<LogIn className="w-5 h-5" />}
|
||||||
|
>
|
||||||
Войти
|
Войти
|
||||||
</Button>
|
</NeonButton>
|
||||||
|
</form>
|
||||||
|
|
||||||
<p className="text-center text-gray-400 text-sm">
|
{/* Footer */}
|
||||||
|
<div className="mt-6 pt-6 border-t border-dark-600 text-center">
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
Нет аккаунта?{' '}
|
Нет аккаунта?{' '}
|
||||||
<Link to="/register" className="link">
|
<Link
|
||||||
|
to="/register"
|
||||||
|
className="text-neon-400 hover:text-neon-300 transition-colors font-medium"
|
||||||
|
>
|
||||||
Зарегистрироваться
|
Зарегистрироваться
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</div>
|
||||||
</CardContent>
|
</>
|
||||||
</Card>
|
)}
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decorative elements */}
|
||||||
|
<div className="absolute -top-4 -right-4 w-24 h-24 border border-neon-500/20 rounded-2xl -z-10 hidden md:block" />
|
||||||
|
<div className="absolute -bottom-4 -left-4 w-32 h-32 border border-accent-500/20 rounded-2xl -z-10 hidden md:block" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,21 @@ import { useState, useEffect, useRef } from 'react'
|
|||||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||||
import { marathonsApi, eventsApi, challengesApi } from '@/api'
|
import { marathonsApi, eventsApi, challengesApi } from '@/api'
|
||||||
import type { Marathon, ActiveEvent, Challenge } from '@/types'
|
import type { Marathon, ActiveEvent, Challenge } from '@/types'
|
||||||
import { Button, Card, CardContent } from '@/components/ui'
|
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { useToast } from '@/store/toast'
|
import { useToast } from '@/store/toast'
|
||||||
import { useConfirm } from '@/store/confirm'
|
import { useConfirm } from '@/store/confirm'
|
||||||
import { EventBanner } from '@/components/EventBanner'
|
import { EventBanner } from '@/components/EventBanner'
|
||||||
import { EventControl } from '@/components/EventControl'
|
import { EventControl } from '@/components/EventControl'
|
||||||
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
|
import { ActivityFeed, type ActivityFeedRef } from '@/components/ActivityFeed'
|
||||||
import { Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2, Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag } from 'lucide-react'
|
import {
|
||||||
|
Users, Calendar, Trophy, Play, Settings, Copy, Check, Loader2, Trash2,
|
||||||
|
Globe, Lock, CalendarCheck, UserPlus, Gamepad2, ArrowLeft, Zap, Flag,
|
||||||
|
Star, Target, TrendingDown, ChevronDown, ChevronUp, Link2, Sparkles
|
||||||
|
} from 'lucide-react'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
|
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 }>()
|
||||||
@@ -27,6 +33,8 @@ export function MarathonPage() {
|
|||||||
const [isJoining, setIsJoining] = useState(false)
|
const [isJoining, setIsJoining] = useState(false)
|
||||||
const [isFinishing, setIsFinishing] = useState(false)
|
const [isFinishing, setIsFinishing] = useState(false)
|
||||||
const [showEventControl, setShowEventControl] = useState(false)
|
const [showEventControl, setShowEventControl] = useState(false)
|
||||||
|
const [showChallenges, setShowChallenges] = useState(false)
|
||||||
|
const [expandedGameId, setExpandedGameId] = useState<number | null>(null)
|
||||||
const activityFeedRef = useRef<ActivityFeedRef>(null)
|
const activityFeedRef = useRef<ActivityFeedRef>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,13 +47,11 @@ export function MarathonPage() {
|
|||||||
const data = await marathonsApi.get(parseInt(id))
|
const data = await marathonsApi.get(parseInt(id))
|
||||||
setMarathon(data)
|
setMarathon(data)
|
||||||
|
|
||||||
// Load event data if marathon is active
|
|
||||||
if (data.status === 'active' && data.my_participation) {
|
if (data.status === 'active' && data.my_participation) {
|
||||||
const eventData = await eventsApi.getActive(parseInt(id))
|
const eventData = await eventsApi.getActive(parseInt(id))
|
||||||
setActiveEvent(eventData)
|
setActiveEvent(eventData)
|
||||||
|
|
||||||
// Load challenges for event control if organizer
|
// Load challenges for all participants
|
||||||
if (data.my_participation.role === 'organizer') {
|
|
||||||
try {
|
try {
|
||||||
const challengesData = await challengesApi.list(parseInt(id))
|
const challengesData = await challengesApi.list(parseInt(id))
|
||||||
setChallenges(challengesData)
|
setChallenges(challengesData)
|
||||||
@@ -53,7 +59,6 @@ export function MarathonPage() {
|
|||||||
// Ignore if no challenges
|
// Ignore if no challenges
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load marathon:', error)
|
console.error('Failed to load marathon:', error)
|
||||||
navigate('/marathons')
|
navigate('/marathons')
|
||||||
@@ -67,7 +72,6 @@ export function MarathonPage() {
|
|||||||
try {
|
try {
|
||||||
const eventData = await eventsApi.getActive(parseInt(id))
|
const eventData = await eventsApi.getActive(parseInt(id))
|
||||||
setActiveEvent(eventData)
|
setActiveEvent(eventData)
|
||||||
// Refresh activity feed when event changes
|
|
||||||
activityFeedRef.current?.refresh()
|
activityFeedRef.current?.refresh()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to refresh event:', error)
|
console.error('Failed to refresh event:', error)
|
||||||
@@ -153,8 +157,9 @@ export function MarathonPage() {
|
|||||||
|
|
||||||
if (isLoading || !marathon) {
|
if (isLoading || !marathon) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-24">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
|
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
|
||||||
|
<p className="text-gray-400">Загрузка марафона...</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -164,265 +169,361 @@ export function MarathonPage() {
|
|||||||
const isCreator = marathon.creator.id === user?.id
|
const isCreator = marathon.creator.id === user?.id
|
||||||
const canDelete = isCreator || user?.role === 'admin'
|
const canDelete = isCreator || user?.role === 'admin'
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
active: { color: 'text-neon-400', bg: 'bg-neon-500/20', border: 'border-neon-500/30', label: 'Активен' },
|
||||||
|
preparing: { color: 'text-yellow-400', bg: 'bg-yellow-500/20', border: 'border-yellow-500/30', label: 'Подготовка' },
|
||||||
|
finished: { color: 'text-gray-400', bg: 'bg-gray-500/20', border: 'border-gray-500/30', label: 'Завершён' },
|
||||||
|
}
|
||||||
|
const status = statusConfig[marathon.status as keyof typeof statusConfig] || statusConfig.finished
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Back button */}
|
{/* Back button */}
|
||||||
<Link to="/marathons" className="inline-flex items-center gap-2 text-gray-400 hover:text-white mb-4 transition-colors">
|
<Link
|
||||||
<ArrowLeft className="w-4 h-4" />
|
to="/marathons"
|
||||||
|
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>
|
</Link>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row gap-6">
|
{/* Hero Banner */}
|
||||||
{/* Main content */}
|
<div className="relative rounded-2xl overflow-hidden mb-8">
|
||||||
<div className="flex-1 min-w-0">
|
{/* Background */}
|
||||||
{/* Header */}
|
<div className="absolute inset-0 bg-gradient-to-br from-neon-500/10 via-dark-800 to-accent-500/10" />
|
||||||
<div className="flex justify-between items-start mb-8">
|
<div className="absolute inset-0 bg-[linear-gradient(rgba(34,211,238,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(34,211,238,0.02)_1px,transparent_1px)] bg-[size:50px_50px]" />
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="relative p-8">
|
||||||
<h1 className="text-3xl font-bold text-white">{marathon.title}</h1>
|
<div className="flex flex-col md:flex-row justify-between items-start gap-6">
|
||||||
<span className={`flex items-center gap-1 text-xs px-2 py-1 rounded ${
|
{/* Title & Description */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mb-3">
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold text-white">{marathon.title}</h1>
|
||||||
|
<span className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border ${
|
||||||
marathon.is_public
|
marathon.is_public
|
||||||
? 'bg-green-900/50 text-green-400'
|
? 'bg-green-500/20 text-green-400 border-green-500/30'
|
||||||
: 'bg-gray-700 text-gray-300'
|
: 'bg-dark-700 text-gray-300 border-dark-600'
|
||||||
}`}>
|
}`}>
|
||||||
{marathon.is_public ? (
|
{marathon.is_public ? <Globe className="w-3 h-3" /> : <Lock className="w-3 h-3" />}
|
||||||
<><Globe className="w-3 h-3" /> Открытый</>
|
{marathon.is_public ? 'Открытый' : 'Закрытый'}
|
||||||
) : (
|
</span>
|
||||||
<><Lock className="w-3 h-3" /> Закрытый</>
|
<span className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border ${status.bg} ${status.color} ${status.border}`}>
|
||||||
)}
|
<span className={`w-2 h-2 rounded-full ${marathon.status === 'active' ? 'bg-neon-500 animate-pulse' : marathon.status === 'preparing' ? 'bg-yellow-500' : 'bg-gray-500'}`} />
|
||||||
|
{status.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{marathon.description && (
|
{marathon.description && (
|
||||||
<p className="text-gray-400">{marathon.description}</p>
|
<p className="text-gray-400 max-w-2xl">{marathon.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 flex-wrap justify-end">
|
{/* Action Buttons */}
|
||||||
{/* Кнопка присоединиться для открытых марафонов */}
|
<div className="flex flex-wrap gap-2">
|
||||||
{marathon.is_public && !isParticipant && marathon.status !== 'finished' && (
|
{marathon.is_public && !isParticipant && marathon.status !== 'finished' && (
|
||||||
<Button onClick={handleJoinPublic} isLoading={isJoining}>
|
<NeonButton onClick={handleJoinPublic} isLoading={isJoining} icon={<UserPlus className="w-4 h-4" />}>
|
||||||
<UserPlus className="w-4 h-4 mr-2" />
|
|
||||||
Присоединиться
|
Присоединиться
|
||||||
</Button>
|
</NeonButton>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Настройка для организаторов */}
|
|
||||||
{marathon.status === 'preparing' && isOrganizer && (
|
{marathon.status === 'preparing' && isOrganizer && (
|
||||||
<Link to={`/marathons/${id}/lobby`}>
|
<Link to={`/marathons/${id}/lobby`}>
|
||||||
<Button variant="secondary">
|
<NeonButton variant="secondary" icon={<Settings className="w-4 h-4" />}>
|
||||||
<Settings className="w-4 h-4 mr-2" />
|
|
||||||
Настройка
|
Настройка
|
||||||
</Button>
|
</NeonButton>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Предложить игру для участников (не организаторов) если разрешено */}
|
|
||||||
{marathon.status === 'preparing' && isParticipant && !isOrganizer && marathon.game_proposal_mode === 'all_participants' && (
|
{marathon.status === 'preparing' && isParticipant && !isOrganizer && marathon.game_proposal_mode === 'all_participants' && (
|
||||||
<Link to={`/marathons/${id}/lobby`}>
|
<Link to={`/marathons/${id}/lobby`}>
|
||||||
<Button variant="secondary">
|
<NeonButton variant="secondary" icon={<Gamepad2 className="w-4 h-4" />}>
|
||||||
<Gamepad2 className="w-4 h-4 mr-2" />
|
|
||||||
Предложить игру
|
Предложить игру
|
||||||
</Button>
|
</NeonButton>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{marathon.status === 'active' && isParticipant && (
|
{marathon.status === 'active' && isParticipant && (
|
||||||
<Link to={`/marathons/${id}/play`}>
|
<Link to={`/marathons/${id}/play`}>
|
||||||
<Button>
|
<NeonButton icon={<Play className="w-4 h-4" />}>
|
||||||
<Play className="w-4 h-4 mr-2" />
|
|
||||||
Играть
|
Играть
|
||||||
</Button>
|
</NeonButton>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link to={`/marathons/${id}/leaderboard`}>
|
<Link to={`/marathons/${id}/leaderboard`}>
|
||||||
<Button variant="secondary">
|
<NeonButton variant="outline" icon={<Trophy className="w-4 h-4" />}>
|
||||||
<Trophy className="w-4 h-4 mr-2" />
|
|
||||||
Рейтинг
|
Рейтинг
|
||||||
</Button>
|
</NeonButton>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{marathon.status === 'active' && isOrganizer && (
|
{marathon.status === 'active' && isOrganizer && (
|
||||||
<Button
|
<button
|
||||||
variant="secondary"
|
|
||||||
onClick={handleFinish}
|
onClick={handleFinish}
|
||||||
isLoading={isFinishing}
|
disabled={isFinishing}
|
||||||
className="text-yellow-400 hover:text-yellow-300 hover:bg-yellow-900/20"
|
className="inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-semibold transition-all duration-200 border border-yellow-500/30 bg-dark-600 text-yellow-400 hover:bg-yellow-500/10 hover:border-yellow-500/50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Flag className="w-4 h-4 mr-2" />
|
{isFinishing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Flag className="w-4 h-4" />}
|
||||||
Завершить
|
Завершить
|
||||||
</Button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<Button
|
<NeonButton
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
isLoading={isDeleting}
|
isLoading={isDeleting}
|
||||||
className="text-red-400 hover:text-red-300 hover:bg-red-900/20"
|
className="!text-red-400 hover:!bg-red-500/10"
|
||||||
>
|
icon={<Trash2 className="w-4 h-4" />}
|
||||||
<Trash2 className="w-4 h-4" />
|
/>
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col lg:flex-row gap-6">
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 min-w-0 space-y-6">
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
<Card>
|
<StatsCard
|
||||||
<CardContent className="text-center py-4">
|
label="Участников"
|
||||||
<div className="text-2xl font-bold text-white">{marathon.participants_count}</div>
|
value={marathon.participants_count}
|
||||||
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
icon={<Users className="w-5 h-5" />}
|
||||||
<Users className="w-4 h-4" />
|
color="neon"
|
||||||
Участников
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="Игр"
|
||||||
|
value={marathon.games_count}
|
||||||
|
icon={<Gamepad2 className="w-5 h-5" />}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="Начало"
|
||||||
|
value={marathon.start_date ? format(new Date(marathon.start_date), 'd MMM', { locale: ru }) : '-'}
|
||||||
|
icon={<Calendar className="w-5 h-5" />}
|
||||||
|
color="default"
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="Конец"
|
||||||
|
value={marathon.end_date ? format(new Date(marathon.end_date), 'd MMM', { locale: ru }) : '-'}
|
||||||
|
icon={<CalendarCheck className="w-5 h-5" />}
|
||||||
|
color="default"
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="Статус"
|
||||||
|
value={status.label}
|
||||||
|
icon={<Target className="w-5 h-5" />}
|
||||||
|
color={marathon.status === 'active' ? 'neon' : marathon.status === 'preparing' ? 'default' : 'default'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
{/* Telegram Bot Banner */}
|
||||||
<CardContent className="text-center py-4">
|
<TelegramBotBanner />
|
||||||
<div className="text-2xl font-bold text-white">{marathon.games_count}</div>
|
|
||||||
<div className="text-sm text-gray-400">Игр</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="text-center py-4">
|
|
||||||
<div className="text-2xl font-bold text-white">
|
|
||||||
{marathon.start_date ? format(new Date(marathon.start_date), 'd MMM') : '-'}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
|
||||||
<Calendar className="w-4 h-4" />
|
|
||||||
Начало
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="text-center py-4">
|
|
||||||
<div className="text-2xl font-bold text-white">
|
|
||||||
{marathon.end_date ? format(new Date(marathon.end_date), 'd MMM') : '-'}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-400 flex items-center justify-center gap-1">
|
|
||||||
<CalendarCheck className="w-4 h-4" />
|
|
||||||
Конец
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="text-center py-4">
|
|
||||||
<div className={`text-2xl font-bold ${
|
|
||||||
marathon.status === 'active' ? 'text-green-500' :
|
|
||||||
marathon.status === 'preparing' ? 'text-yellow-500' : 'text-gray-400'
|
|
||||||
}`}>
|
|
||||||
{marathon.status === 'active' ? 'Активен' : marathon.status === 'preparing' ? 'Подготовка' : 'Завершён'}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-400">Статус</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active event banner */}
|
{/* Active event banner */}
|
||||||
{marathon.status === 'active' && activeEvent?.event && (
|
{marathon.status === 'active' && activeEvent?.event && (
|
||||||
<div className="mb-8">
|
|
||||||
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
|
<EventBanner activeEvent={activeEvent} onRefresh={refreshEvent} />
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Event control for organizers */}
|
{/* Event control for organizers */}
|
||||||
{marathon.status === 'active' && isOrganizer && (
|
{marathon.status === 'active' && isOrganizer && (
|
||||||
<Card className="mb-8">
|
<GlassCard>
|
||||||
<CardContent>
|
<button
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="font-medium text-white flex items-center gap-2">
|
|
||||||
<Zap className="w-5 h-5 text-yellow-500" />
|
|
||||||
Управление событиями
|
|
||||||
</h3>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowEventControl(!showEventControl)}
|
onClick={() => setShowEventControl(!showEventControl)}
|
||||||
|
className="w-full flex items-center justify-between"
|
||||||
>
|
>
|
||||||
{showEventControl ? 'Скрыть' : 'Показать'}
|
<div className="flex items-center gap-3">
|
||||||
</Button>
|
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center">
|
||||||
|
<Zap className="w-5 h-5 text-yellow-400" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<h3 className="font-semibold text-white">Управление событиями</h3>
|
||||||
|
<p className="text-sm text-gray-400">Активируйте бонусы для участников</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showEventControl ? (
|
||||||
|
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
{showEventControl && activeEvent && (
|
{showEventControl && activeEvent && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-dark-600">
|
||||||
<EventControl
|
<EventControl
|
||||||
marathonId={marathon.id}
|
marathonId={marathon.id}
|
||||||
activeEvent={activeEvent}
|
activeEvent={activeEvent}
|
||||||
challenges={challenges}
|
challenges={challenges}
|
||||||
onEventChange={refreshEvent}
|
onEventChange={refreshEvent}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</GlassCard>
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Invite link */}
|
{/* Invite link */}
|
||||||
{marathon.status !== 'finished' && (
|
{marathon.status !== 'finished' && (
|
||||||
<Card className="mb-8">
|
<GlassCard>
|
||||||
<CardContent>
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<h3 className="font-medium text-white mb-3">Ссылка для приглашения</h3>
|
<div className="w-10 h-10 rounded-xl bg-accent-500/20 flex items-center justify-center">
|
||||||
|
<Link2 className="w-5 h-5 text-accent-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">Пригласить друзей</h3>
|
||||||
|
<p className="text-sm text-gray-400">Поделитесь ссылкой</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<code className="flex-1 px-4 py-2 bg-gray-900 rounded-lg text-primary-400 font-mono text-sm overflow-hidden text-ellipsis">
|
<code className="flex-1 px-4 py-3 bg-dark-700 rounded-xl text-neon-400 font-mono text-sm overflow-hidden text-ellipsis border border-dark-600">
|
||||||
{getInviteLink()}
|
{getInviteLink()}
|
||||||
</code>
|
</code>
|
||||||
<Button variant="secondary" onClick={copyInviteLink}>
|
<NeonButton variant="secondary" onClick={copyInviteLink} icon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}>
|
||||||
{copied ? (
|
{copied ? 'Скопировано!' : 'Копировать'}
|
||||||
<>
|
</NeonButton>
|
||||||
<Check className="w-4 h-4 mr-2" />
|
|
||||||
Скопировано!
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Copy className="w-4 h-4 mr-2" />
|
|
||||||
Копировать
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500 mt-2">
|
</GlassCard>
|
||||||
Поделитесь этой ссылкой с друзьями, чтобы они могли присоединиться к марафону
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* My stats */}
|
{/* My stats */}
|
||||||
{marathon.my_participation && (
|
{marathon.my_participation && (
|
||||||
<Card>
|
<GlassCard variant="neon">
|
||||||
<CardContent>
|
<h3 className="font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
<h3 className="font-medium text-white mb-4">Ваша статистика</h3>
|
<Star className="w-5 h-5 text-yellow-500" />
|
||||||
<div className="grid grid-cols-3 gap-4 text-center">
|
Ваша статистика
|
||||||
<div>
|
</h3>
|
||||||
<div className="text-2xl font-bold text-primary-500">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="text-center p-4 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||||
|
<div className="text-3xl font-bold text-neon-400">
|
||||||
{marathon.my_participation.total_points}
|
{marathon.my_participation.total_points}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-400">Очков</div>
|
<div className="text-sm text-gray-400 mt-1">Очков</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="text-center p-4 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||||
<div className="text-2xl font-bold text-yellow-500">
|
<div className="text-3xl font-bold text-yellow-400 flex items-center justify-center gap-1">
|
||||||
{marathon.my_participation.current_streak}
|
{marathon.my_participation.current_streak}
|
||||||
|
{marathon.my_participation.current_streak > 0 && (
|
||||||
|
<span className="text-lg">🔥</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-400">Серия</div>
|
<div className="text-sm text-gray-400 mt-1">Серия</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="text-center p-4 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||||
<div className="text-2xl font-bold text-gray-400">
|
<div className="text-3xl font-bold text-gray-400 flex items-center justify-center gap-1">
|
||||||
{marathon.my_participation.drop_count}
|
{marathon.my_participation.drop_count}
|
||||||
|
<TrendingDown className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-400">Пропусков</div>
|
<div className="text-sm text-gray-400 mt-1">Пропусков</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</GlassCard>
|
||||||
</Card>
|
)}
|
||||||
|
|
||||||
|
{/* All challenges viewer */}
|
||||||
|
{marathon.status === 'active' && isParticipant && challenges.length > 0 && (
|
||||||
|
<GlassCard>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowChallenges(!showChallenges)}
|
||||||
|
className="w-full flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<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 className="text-left">
|
||||||
|
<h3 className="font-semibold text-white">Все задания</h3>
|
||||||
|
<p className="text-sm text-gray-400">{challenges.length} заданий для {new Set(challenges.map(c => c.game.id)).size} игр</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showChallenges ? (
|
||||||
|
<ChevronUp className="w-5 h-5 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-5 h-5 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{showChallenges && (
|
||||||
|
<div className="mt-6 pt-6 border-t border-dark-600 space-y-4">
|
||||||
|
{/* Group challenges by game */}
|
||||||
|
{Array.from(new Set(challenges.map(c => c.game.id))).map(gameId => {
|
||||||
|
const gameChallenges = challenges.filter(c => c.game.id === gameId)
|
||||||
|
const game = gameChallenges[0]?.game
|
||||||
|
if (!game) return null
|
||||||
|
const isExpanded = expandedGameId === gameId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={gameId} className="glass rounded-xl overflow-hidden border border-dark-600">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedGameId(isExpanded ? null : gameId)}
|
||||||
|
className="w-full flex items-center justify-between p-4 hover:bg-dark-700/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-gray-400">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-neon-500/20 to-accent-500/20 border border-neon-500/20 flex items-center justify-center">
|
||||||
|
<Gamepad2 className="w-4 h-4 text-neon-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<h4 className="font-semibold text-white">{game.title}</h4>
|
||||||
|
<span className="text-xs text-gray-400">{gameChallenges.length} заданий</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-dark-600 p-4 space-y-2 bg-dark-800/30">
|
||||||
|
{gameChallenges.map(challenge => (
|
||||||
|
<div
|
||||||
|
key={challenge.id}
|
||||||
|
className="p-3 bg-dark-700/50 rounded-lg border border-dark-600"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
|
<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>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{challenge.type === 'completion' ? 'Прохождение' :
|
||||||
|
challenge.type === 'no_death' ? 'Без смертей' :
|
||||||
|
challenge.type === 'speedrun' ? 'Спидран' :
|
||||||
|
challenge.type === 'collection' ? 'Коллекция' :
|
||||||
|
challenge.type === 'achievement' ? 'Достижение' : 'Челлендж-ран'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h5 className="text-sm font-medium text-white">{challenge.title}</h5>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{challenge.description}</p>
|
||||||
|
{challenge.proof_hint && (
|
||||||
|
<p className="text-xs text-gray-500 mt-2 flex items-center gap-1">
|
||||||
|
<Target className="w-3 h-3" />
|
||||||
|
Пруф: {challenge.proof_hint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</GlassCard>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Activity Feed - right sidebar */}
|
{/* Activity Feed - right sidebar */}
|
||||||
{isParticipant && (
|
{isParticipant && (
|
||||||
<div className="lg:w-96 flex-shrink-0">
|
<div className="lg:w-96 flex-shrink-0">
|
||||||
<div className="lg:sticky lg:top-4">
|
<div className="lg:sticky lg:top-24">
|
||||||
<ActivityFeed
|
<ActivityFeed
|
||||||
ref={activityFeedRef}
|
ref={activityFeedRef}
|
||||||
marathonId={marathon.id}
|
marathonId={marathon.id}
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import { useState, useEffect } from 'react'
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { marathonsApi } from '@/api'
|
import { marathonsApi } from '@/api'
|
||||||
import type { MarathonListItem } from '@/types'
|
import type { MarathonListItem } from '@/types'
|
||||||
import { Button, Card, CardContent } from '@/components/ui'
|
import { NeonButton, GlassCard, StatsCard } from '@/components/ui'
|
||||||
import { Plus, Users, Calendar, Loader2 } 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'
|
||||||
|
|
||||||
export function MarathonsPage() {
|
export function MarathonsPage() {
|
||||||
const [marathons, setMarathons] = useState<MarathonListItem[]>([])
|
const [marathons, setMarathons] = useState<MarathonListItem[]>([])
|
||||||
@@ -12,6 +15,7 @@ export function MarathonsPage() {
|
|||||||
const [joinCode, setJoinCode] = useState('')
|
const [joinCode, setJoinCode] = useState('')
|
||||||
const [joinError, setJoinError] = useState<string | null>(null)
|
const [joinError, setJoinError] = useState<string | null>(null)
|
||||||
const [isJoining, setIsJoining] = useState(false)
|
const [isJoining, setIsJoining] = useState(false)
|
||||||
|
const [showJoinSection, setShowJoinSection] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMarathons()
|
loadMarathons()
|
||||||
@@ -36,6 +40,7 @@ export function MarathonsPage() {
|
|||||||
try {
|
try {
|
||||||
await marathonsApi.join(joinCode.trim())
|
await marathonsApi.join(joinCode.trim())
|
||||||
setJoinCode('')
|
setJoinCode('')
|
||||||
|
setShowJoinSection(false)
|
||||||
await loadMarathons()
|
await loadMarathons()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
@@ -45,112 +50,227 @@ export function MarathonsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusConfig = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'preparing':
|
case 'preparing':
|
||||||
return 'bg-yellow-500/20 text-yellow-500'
|
return {
|
||||||
|
color: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||||
|
text: 'Подготовка',
|
||||||
|
dot: 'bg-yellow-500',
|
||||||
|
}
|
||||||
case 'active':
|
case 'active':
|
||||||
return 'bg-green-500/20 text-green-500'
|
return {
|
||||||
|
color: 'bg-neon-500/20 text-neon-400 border-neon-500/30',
|
||||||
|
text: 'Активен',
|
||||||
|
dot: 'bg-neon-500 animate-pulse',
|
||||||
|
}
|
||||||
case 'finished':
|
case 'finished':
|
||||||
return 'bg-gray-500/20 text-gray-400'
|
return {
|
||||||
|
color: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||||
|
text: 'Завершён',
|
||||||
|
dot: 'bg-gray-500',
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return 'bg-gray-500/20 text-gray-400'
|
return {
|
||||||
|
color: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||||
|
text: status,
|
||||||
|
dot: 'bg-gray-500',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusText = (status: string) => {
|
// Stats
|
||||||
switch (status) {
|
const activeCount = marathons.filter(m => m.status === 'active').length
|
||||||
case 'preparing':
|
const completedCount = marathons.filter(m => m.status === 'finished').length
|
||||||
return 'Подготовка'
|
const totalParticipants = marathons.reduce((acc, m) => acc + m.participants_count, 0)
|
||||||
case 'active':
|
|
||||||
return 'Активен'
|
|
||||||
case 'finished':
|
|
||||||
return 'Завершён'
|
|
||||||
default:
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-24">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
|
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
|
||||||
|
<p className="text-gray-400">Загрузка марафонов...</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
<div className="flex justify-between items-center mb-8">
|
{/* Header */}
|
||||||
<h1 className="text-2xl font-bold text-white">Мои марафоны</h1>
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Мои марафоны</h1>
|
||||||
|
<p className="text-gray-400">Управляйте своими игровыми соревнованиями</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<NeonButton
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowJoinSection(!showJoinSection)}
|
||||||
|
icon={<Hash className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
По коду
|
||||||
|
</NeonButton>
|
||||||
<Link to="/marathons/create">
|
<Link to="/marathons/create">
|
||||||
<Button>
|
<NeonButton icon={<Plus className="w-4 h-4" />}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
Создать
|
||||||
Создать марафон
|
</NeonButton>
|
||||||
</Button>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
{marathons.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<StatsCard
|
||||||
|
label="Всего"
|
||||||
|
value={marathons.length}
|
||||||
|
icon={<Gamepad2 className="w-6 h-6" />}
|
||||||
|
color="default"
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="Активных"
|
||||||
|
value={activeCount}
|
||||||
|
icon={<Sparkles className="w-6 h-6" />}
|
||||||
|
color="neon"
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="Завершено"
|
||||||
|
value={completedCount}
|
||||||
|
icon={<Trophy className="w-6 h-6" />}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="Участников"
|
||||||
|
value={totalParticipants}
|
||||||
|
icon={<Users className="w-6 h-6" />}
|
||||||
|
color="pink"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Announcement Banner */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<AnnouncementBanner />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Telegram Bot Banner */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<TelegramBotBanner />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Join marathon */}
|
{/* Join marathon */}
|
||||||
<Card className="mb-8">
|
{showJoinSection && (
|
||||||
<CardContent>
|
<GlassCard className="mb-8 animate-slide-in-down" variant="neon">
|
||||||
<h3 className="font-medium text-white mb-3">Присоединиться к марафону</h3>
|
<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">
|
||||||
|
<Hash className="w-5 h-5 text-accent-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-white">Присоединиться к марафону</h3>
|
||||||
|
<p className="text-sm text-gray-400">Введите код приглашения</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={joinCode}
|
value={joinCode}
|
||||||
onChange={(e) => setJoinCode(e.target.value)}
|
onChange={(e) => setJoinCode(e.target.value.toUpperCase())}
|
||||||
placeholder="Введите код приглашения"
|
onKeyDown={(e) => e.key === 'Enter' && handleJoin()}
|
||||||
className="input flex-1"
|
placeholder="XXXXXX"
|
||||||
|
className="input flex-1 font-mono text-center tracking-widest uppercase"
|
||||||
|
maxLength={10}
|
||||||
/>
|
/>
|
||||||
<Button onClick={handleJoin} isLoading={isJoining}>
|
<NeonButton
|
||||||
|
onClick={handleJoin}
|
||||||
|
isLoading={isJoining}
|
||||||
|
color="purple"
|
||||||
|
>
|
||||||
Присоединиться
|
Присоединиться
|
||||||
</Button>
|
</NeonButton>
|
||||||
</div>
|
</div>
|
||||||
{joinError && <p className="mt-2 text-sm text-red-500">{joinError}</p>}
|
{joinError && (
|
||||||
</CardContent>
|
<p className="mt-3 text-sm text-red-400 flex items-center gap-2">
|
||||||
</Card>
|
<span className="w-1.5 h-1.5 bg-red-500 rounded-full" />
|
||||||
|
{joinError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Marathon list */}
|
{/* Marathon list */}
|
||||||
{marathons.length === 0 ? (
|
{marathons.length === 0 ? (
|
||||||
<Card>
|
<GlassCard className="text-center py-16">
|
||||||
<CardContent className="text-center py-8">
|
<div className="w-20 h-20 mx-auto mb-6 rounded-2xl bg-dark-700 flex items-center justify-center">
|
||||||
<p className="text-gray-400 mb-4">У вас пока нет марафонов</p>
|
<Gamepad2 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 max-w-sm mx-auto">
|
||||||
|
Создайте свой первый марафон или присоединитесь к существующему по коду
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 justify-center">
|
||||||
|
<NeonButton
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowJoinSection(true)}
|
||||||
|
icon={<Hash className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Ввести код
|
||||||
|
</NeonButton>
|
||||||
<Link to="/marathons/create">
|
<Link to="/marathons/create">
|
||||||
<Button>Создать первый марафон</Button>
|
<NeonButton icon={<Plus className="w-4 h-4" />}>
|
||||||
|
Создать марафон
|
||||||
|
</NeonButton>
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</GlassCard>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{marathons.map((marathon) => (
|
{marathons.map((marathon, index) => {
|
||||||
|
const status = getStatusConfig(marathon.status)
|
||||||
|
return (
|
||||||
<Link key={marathon.id} to={`/marathons/${marathon.id}`}>
|
<Link key={marathon.id} to={`/marathons/${marathon.id}`}>
|
||||||
<Card className="hover:bg-gray-700/50 transition-colors cursor-pointer">
|
<div
|
||||||
<CardContent className="flex items-center justify-between">
|
className="group glass rounded-xl p-5 border border-dark-600 transition-all duration-300 hover:border-neon-500/30 hover:-translate-y-0.5 hover:shadow-[0_10px_40px_rgba(34,211,238,0.08)]"
|
||||||
|
style={{ animationDelay: `${index * 50}ms` }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-neon-500/20 to-accent-500/20 flex items-center justify-center border border-neon-500/20 group-hover:border-neon-500/40 transition-colors">
|
||||||
|
<Gamepad2 className="w-7 h-7 text-neon-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium text-white mb-1">
|
<h3 className="text-lg font-semibold text-white group-hover:text-neon-400 transition-colors mb-1">
|
||||||
{marathon.title}
|
{marathon.title}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-4 text-sm text-gray-400">
|
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-400">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1.5">
|
||||||
<Users className="w-4 h-4" />
|
<Users className="w-4 h-4" />
|
||||||
{marathon.participants_count} участников
|
{marathon.participants_count}
|
||||||
</span>
|
</span>
|
||||||
{marathon.start_date && (
|
{marathon.start_date && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1.5">
|
||||||
<Calendar className="w-4 h-4" />
|
<Calendar className="w-4 h-4" />
|
||||||
{format(new Date(marathon.start_date), 'MMM d, yyyy')}
|
{format(new Date(marathon.start_date), 'd MMM yyyy', { locale: ru })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={`px-3 py-1 rounded-full text-sm ${getStatusColor(marathon.status)}`}>
|
</div>
|
||||||
{getStatusText(marathon.status)}
|
|
||||||
|
{/* Status & Arrow */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className={`px-3 py-1.5 rounded-full text-sm font-medium border flex items-center gap-2 ${status.color}`}>
|
||||||
|
<span className={`w-2 h-2 rounded-full ${status.dot}`} />
|
||||||
|
{status.text}
|
||||||
</span>
|
</span>
|
||||||
</CardContent>
|
<ChevronRight className="w-5 h-5 text-gray-600 group-hover:text-neon-400 transition-colors" />
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,33 +1,62 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Button } from '@/components/ui'
|
import { NeonButton } from '@/components/ui'
|
||||||
import { Gamepad2, Home, Ghost } from 'lucide-react'
|
import { Gamepad2, Home, Ghost, Sparkles } from 'lucide-react'
|
||||||
|
|
||||||
export function NotFoundPage() {
|
export function NotFoundPage() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[60vh] flex flex-col items-center justify-center text-center px-4">
|
<div className="min-h-[70vh] flex flex-col items-center justify-center text-center px-4">
|
||||||
{/* Иконка с анимацией */}
|
{/* Background effects */}
|
||||||
<div className="relative mb-8">
|
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||||
<Ghost className="w-32 h-32 text-gray-700 animate-bounce" />
|
<div className="absolute top-1/3 -left-32 w-96 h-96 bg-accent-500/5 rounded-full blur-[100px]" />
|
||||||
<Gamepad2 className="w-12 h-12 text-primary-500 absolute -bottom-2 -right-2" />
|
<div className="absolute bottom-1/3 -right-32 w-96 h-96 bg-neon-500/5 rounded-full blur-[100px]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Заголовок */}
|
{/* Icon */}
|
||||||
<h1 className="text-7xl font-bold text-white mb-4">404</h1>
|
<div className="relative mb-8 animate-float">
|
||||||
<h2 className="text-2xl font-semibold text-gray-400 mb-2">
|
<div className="w-32 h-32 rounded-3xl bg-dark-700/50 border border-dark-600 flex items-center justify-center">
|
||||||
|
<Ghost className="w-20 h-20 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute -bottom-2 -right-2 w-12 h-12 rounded-xl bg-neon-500/20 border border-neon-500/30 flex items-center justify-center">
|
||||||
|
<Gamepad2 className="w-6 h-6 text-neon-400" />
|
||||||
|
</div>
|
||||||
|
{/* Glitch effect dots */}
|
||||||
|
<div className="absolute -top-2 -left-2 w-3 h-3 rounded-full bg-accent-500/50 animate-pulse" />
|
||||||
|
<div className="absolute top-4 -right-4 w-2 h-2 rounded-full bg-neon-500/50 animate-pulse" style={{ animationDelay: '0.5s' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 404 text with glitch effect */}
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<h1 className="text-8xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-neon-400 via-accent-400 to-pink-400">
|
||||||
|
404
|
||||||
|
</h1>
|
||||||
|
<div className="absolute inset-0 text-8xl font-bold text-neon-500/20 blur-xl">
|
||||||
|
404
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-3">
|
||||||
Страница не найдена
|
Страница не найдена
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-500 mb-8 max-w-md">
|
<p className="text-gray-400 mb-8 max-w-md">
|
||||||
Похоже, эта страница ушла на марафон и не вернулась.
|
Похоже, эта страница ушла на марафон и не вернулась.
|
||||||
Попробуй начать с главной.
|
<br />
|
||||||
|
<span className="text-gray-500">Попробуй начать с главной.</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Кнопка */}
|
{/* Button */}
|
||||||
<Link to="/">
|
<Link to="/">
|
||||||
<Button size="lg" className="flex items-center gap-2">
|
<NeonButton size="lg" icon={<Home className="w-5 h-5" />}>
|
||||||
<Home className="w-5 h-5" />
|
|
||||||
На главную
|
На главную
|
||||||
</Button>
|
</NeonButton>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Decorative sparkles */}
|
||||||
|
<div className="absolute top-1/4 left-1/4 opacity-20">
|
||||||
|
<Sparkles className="w-6 h-6 text-neon-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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,15 +7,15 @@ import { usersApi, telegramApi, authApi } from '@/api'
|
|||||||
import type { UserStats } from '@/types'
|
import type { UserStats } from '@/types'
|
||||||
import { useToast } from '@/store/toast'
|
import { useToast } from '@/store/toast'
|
||||||
import {
|
import {
|
||||||
Button, Input, Card, CardHeader, CardTitle, CardContent, clearAvatarCache
|
NeonButton, Input, GlassCard, StatsCard, clearAvatarCache
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import {
|
import {
|
||||||
User, Camera, Trophy, Target, CheckCircle, Flame,
|
User, Camera, Trophy, Target, CheckCircle, Flame,
|
||||||
Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
|
Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
|
||||||
Eye, EyeOff, Save, KeyRound
|
Eye, EyeOff, Save, KeyRound, Shield
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// Схемы валидации
|
// Schemas
|
||||||
const nicknameSchema = z.object({
|
const nicknameSchema = z.object({
|
||||||
nickname: z.string().min(2, 'Минимум 2 символа').max(50, 'Максимум 50 символов'),
|
nickname: z.string().min(2, 'Минимум 2 символа').max(50, 'Максимум 50 символов'),
|
||||||
})
|
})
|
||||||
@@ -33,10 +33,10 @@ type NicknameForm = z.infer<typeof nicknameSchema>
|
|||||||
type PasswordForm = z.infer<typeof passwordSchema>
|
type PasswordForm = z.infer<typeof passwordSchema>
|
||||||
|
|
||||||
export function ProfilePage() {
|
export function ProfilePage() {
|
||||||
const { user, updateUser } = useAuthStore()
|
const { user, updateUser, avatarVersion, bumpAvatarVersion } = useAuthStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
// Состояние
|
// State
|
||||||
const [stats, setStats] = useState<UserStats | null>(null)
|
const [stats, setStats] = useState<UserStats | null>(null)
|
||||||
const [isLoadingStats, setIsLoadingStats] = useState(true)
|
const [isLoadingStats, setIsLoadingStats] = useState(true)
|
||||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
|
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
|
||||||
@@ -53,7 +53,7 @@ export function ProfilePage() {
|
|||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// Формы
|
// Forms
|
||||||
const nicknameForm = useForm<NicknameForm>({
|
const nicknameForm = useForm<NicknameForm>({
|
||||||
resolver: zodResolver(nicknameSchema),
|
resolver: zodResolver(nicknameSchema),
|
||||||
defaultValues: { nickname: user?.nickname || '' },
|
defaultValues: { nickname: user?.nickname || '' },
|
||||||
@@ -64,7 +64,7 @@ export function ProfilePage() {
|
|||||||
defaultValues: { current_password: '', new_password: '', confirm_password: '' },
|
defaultValues: { current_password: '', new_password: '', confirm_password: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Загрузка статистики
|
// Load stats
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadStats()
|
loadStats()
|
||||||
return () => {
|
return () => {
|
||||||
@@ -72,33 +72,59 @@ export function ProfilePage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Загрузка аватарки через API
|
// Ref для отслеживания текущего blob URL
|
||||||
|
const avatarBlobRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
// Load avatar via API
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.id && user?.avatar_url) {
|
if (!user?.id || !user?.avatar_url) {
|
||||||
loadAvatar(user.id)
|
|
||||||
} else {
|
|
||||||
setIsLoadingAvatar(false)
|
setIsLoadingAvatar(false)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return () => {
|
|
||||||
if (avatarBlobUrl) {
|
|
||||||
URL.revokeObjectURL(avatarBlobUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [user?.id, user?.avatar_url])
|
|
||||||
|
|
||||||
const loadAvatar = async (userId: number) => {
|
let cancelled = false
|
||||||
|
const bustCache = avatarVersion > 0
|
||||||
|
|
||||||
setIsLoadingAvatar(true)
|
setIsLoadingAvatar(true)
|
||||||
try {
|
usersApi.getAvatarUrl(user.id, bustCache)
|
||||||
const url = await usersApi.getAvatarUrl(userId)
|
.then(url => {
|
||||||
|
if (cancelled) {
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Очищаем старый blob URL
|
||||||
|
if (avatarBlobRef.current) {
|
||||||
|
URL.revokeObjectURL(avatarBlobRef.current)
|
||||||
|
}
|
||||||
|
avatarBlobRef.current = url
|
||||||
setAvatarBlobUrl(url)
|
setAvatarBlobUrl(url)
|
||||||
} catch {
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
setAvatarBlobUrl(null)
|
setAvatarBlobUrl(null)
|
||||||
} finally {
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) {
|
||||||
setIsLoadingAvatar(false)
|
setIsLoadingAvatar(false)
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
// Обновляем форму никнейма при изменении user
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [user?.id, user?.avatar_url, avatarVersion])
|
||||||
|
|
||||||
|
// Cleanup blob URL on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (avatarBlobRef.current) {
|
||||||
|
URL.revokeObjectURL(avatarBlobRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Update nickname form when user changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.nickname) {
|
if (user?.nickname) {
|
||||||
nicknameForm.reset({ nickname: user.nickname })
|
nicknameForm.reset({ nickname: user.nickname })
|
||||||
@@ -116,7 +142,7 @@ export function ProfilePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновление никнейма
|
// Update nickname
|
||||||
const onNicknameSubmit = async (data: NicknameForm) => {
|
const onNicknameSubmit = async (data: NicknameForm) => {
|
||||||
try {
|
try {
|
||||||
const updatedUser = await usersApi.updateNickname(data)
|
const updatedUser = await usersApi.updateNickname(data)
|
||||||
@@ -127,7 +153,7 @@ export function ProfilePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загрузка аватара
|
// Upload avatar
|
||||||
const handleAvatarClick = () => {
|
const handleAvatarClick = () => {
|
||||||
fileInputRef.current?.click()
|
fileInputRef.current?.click()
|
||||||
}
|
}
|
||||||
@@ -136,7 +162,6 @@ export function ProfilePage() {
|
|||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
// Валидация
|
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
toast.error('Файл должен быть изображением')
|
toast.error('Файл должен быть изображением')
|
||||||
return
|
return
|
||||||
@@ -150,15 +175,11 @@ export function ProfilePage() {
|
|||||||
try {
|
try {
|
||||||
const updatedUser = await usersApi.uploadAvatar(file)
|
const updatedUser = await usersApi.uploadAvatar(file)
|
||||||
updateUser({ avatar_url: updatedUser.avatar_url })
|
updateUser({ avatar_url: updatedUser.avatar_url })
|
||||||
// Перезагружаем аватарку через API
|
|
||||||
if (user?.id) {
|
if (user?.id) {
|
||||||
// Очищаем старый blob URL и глобальный кэш
|
|
||||||
if (avatarBlobUrl) {
|
|
||||||
URL.revokeObjectURL(avatarBlobUrl)
|
|
||||||
}
|
|
||||||
clearAvatarCache(user.id)
|
clearAvatarCache(user.id)
|
||||||
await loadAvatar(user.id)
|
|
||||||
}
|
}
|
||||||
|
// Bump version - это вызовет перезагрузку через useEffect
|
||||||
|
bumpAvatarVersion()
|
||||||
toast.success('Аватар обновлен')
|
toast.success('Аватар обновлен')
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Не удалось загрузить аватар')
|
toast.error('Не удалось загрузить аватар')
|
||||||
@@ -167,7 +188,7 @@ export function ProfilePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Смена пароля
|
// Change password
|
||||||
const onPasswordSubmit = async (data: PasswordForm) => {
|
const onPasswordSubmit = async (data: PasswordForm) => {
|
||||||
try {
|
try {
|
||||||
await usersApi.changePassword({
|
await usersApi.changePassword({
|
||||||
@@ -184,7 +205,7 @@ export function ProfilePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Telegram функции
|
// Telegram functions
|
||||||
const startPolling = () => {
|
const startPolling = () => {
|
||||||
setIsPolling(true)
|
setIsPolling(true)
|
||||||
let attempts = 0
|
let attempts = 0
|
||||||
@@ -245,26 +266,28 @@ export function ProfilePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isLinked = !!user?.telegram_id
|
const isLinked = !!user?.telegram_id
|
||||||
// Приоритет: загруженная аватарка (blob) > телеграм аватарка
|
|
||||||
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
|
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto space-y-6">
|
<div className="max-w-3xl mx-auto space-y-6">
|
||||||
<h1 className="text-2xl font-bold text-white">Мой профиль</h1>
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-white mb-2">Мой профиль</h1>
|
||||||
|
<p className="text-gray-400">Настройки вашего аккаунта</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Карточка профиля */}
|
{/* Profile Card */}
|
||||||
<Card>
|
<GlassCard variant="neon">
|
||||||
<CardContent className="pt-6">
|
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6">
|
||||||
<div className="flex items-start gap-6">
|
{/* Avatar */}
|
||||||
{/* Аватар */}
|
|
||||||
<div className="relative group flex-shrink-0">
|
<div className="relative group flex-shrink-0">
|
||||||
{isLoadingAvatar ? (
|
{isLoadingAvatar ? (
|
||||||
<div className="w-24 h-24 rounded-full bg-gray-700 animate-pulse" />
|
<div className="w-28 h-28 rounded-2xl bg-dark-700 skeleton" />
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={handleAvatarClick}
|
onClick={handleAvatarClick}
|
||||||
disabled={isUploadingAvatar}
|
disabled={isUploadingAvatar}
|
||||||
className="relative w-24 h-24 rounded-full overflow-hidden bg-gray-700 hover:opacity-80 transition-opacity"
|
className="relative w-28 h-28 rounded-2xl overflow-hidden bg-dark-700 border-2 border-neon-500/30 hover:border-neon-500 transition-all group-hover:shadow-[0_0_20px_rgba(34,211,238,0.25)]"
|
||||||
>
|
>
|
||||||
{displayAvatar ? (
|
{displayAvatar ? (
|
||||||
<img
|
<img
|
||||||
@@ -273,15 +296,15 @@ export function ProfilePage() {
|
|||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-neon-500/20 to-accent-500/20">
|
||||||
<User className="w-12 h-12 text-gray-500" />
|
<User className="w-12 h-12 text-gray-500" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="absolute inset-0 bg-dark-900/70 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
{isUploadingAvatar ? (
|
{isUploadingAvatar ? (
|
||||||
<Loader2 className="w-6 h-6 text-white animate-spin" />
|
<Loader2 className="w-8 h-8 text-neon-500 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Camera className="w-6 h-6 text-white" />
|
<Camera className="w-8 h-8 text-neon-500" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -295,90 +318,96 @@ export function ProfilePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Форма никнейма */}
|
{/* Nickname Form */}
|
||||||
<div className="flex-1">
|
<div className="flex-1 w-full sm:w-auto">
|
||||||
<form onSubmit={nicknameForm.handleSubmit(onNicknameSubmit)} className="space-y-4">
|
<form onSubmit={nicknameForm.handleSubmit(onNicknameSubmit)} className="space-y-4">
|
||||||
<Input
|
<Input
|
||||||
label="Никнейм"
|
label="Никнейм"
|
||||||
{...nicknameForm.register('nickname')}
|
{...nicknameForm.register('nickname')}
|
||||||
error={nicknameForm.formState.errors.nickname?.message}
|
error={nicknameForm.formState.errors.nickname?.message}
|
||||||
/>
|
/>
|
||||||
<Button
|
<NeonButton
|
||||||
type="submit"
|
type="submit"
|
||||||
size="sm"
|
size="sm"
|
||||||
isLoading={nicknameForm.formState.isSubmitting}
|
isLoading={nicknameForm.formState.isSubmitting}
|
||||||
disabled={!nicknameForm.formState.isDirty}
|
disabled={!nicknameForm.formState.isDirty}
|
||||||
|
icon={<Save className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
<Save className="w-4 h-4 mr-2" />
|
|
||||||
Сохранить
|
Сохранить
|
||||||
</Button>
|
</NeonButton>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</GlassCard>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Статистика */}
|
{/* Stats */}
|
||||||
<Card>
|
<div>
|
||||||
<CardHeader>
|
<h2 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Trophy className="w-5 h-5 text-yellow-500" />
|
<Trophy className="w-5 h-5 text-yellow-500" />
|
||||||
Статистика
|
Статистика
|
||||||
</CardTitle>
|
</h2>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoadingStats ? (
|
{isLoadingStats ? (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
{[...Array(4)].map((_, i) => (
|
{[...Array(4)].map((_, i) => (
|
||||||
<div key={i} className="bg-gray-900 rounded-lg p-4 text-center">
|
<div key={i} className="glass rounded-xl p-4">
|
||||||
<div className="w-6 h-6 bg-gray-700 rounded mx-auto mb-2 animate-pulse" />
|
<div className="w-12 h-12 bg-dark-700 rounded-lg mb-3 skeleton" />
|
||||||
<div className="h-8 w-12 bg-gray-700 rounded mx-auto mb-2 animate-pulse" />
|
<div className="h-8 w-16 bg-dark-700 rounded mb-2 skeleton" />
|
||||||
<div className="h-4 w-16 bg-gray-700 rounded mx-auto animate-pulse" />
|
<div className="h-4 w-20 bg-dark-700 rounded skeleton" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : stats ? (
|
) : stats ? (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div className="bg-gray-900 rounded-lg p-4 text-center">
|
<StatsCard
|
||||||
<Target className="w-6 h-6 text-blue-400 mx-auto mb-2" />
|
label="Марафонов"
|
||||||
<div className="text-2xl font-bold text-white">{stats.marathons_count}</div>
|
value={stats.marathons_count}
|
||||||
<div className="text-sm text-gray-400">Марафонов</div>
|
icon={<Target className="w-6 h-6" />}
|
||||||
</div>
|
color="neon"
|
||||||
<div className="bg-gray-900 rounded-lg p-4 text-center">
|
/>
|
||||||
<Trophy className="w-6 h-6 text-yellow-500 mx-auto mb-2" />
|
<StatsCard
|
||||||
<div className="text-2xl font-bold text-white">{stats.wins_count}</div>
|
label="Побед"
|
||||||
<div className="text-sm text-gray-400">Побед</div>
|
value={stats.wins_count}
|
||||||
</div>
|
icon={<Trophy className="w-6 h-6" />}
|
||||||
<div className="bg-gray-900 rounded-lg p-4 text-center">
|
color="purple"
|
||||||
<CheckCircle className="w-6 h-6 text-green-500 mx-auto mb-2" />
|
/>
|
||||||
<div className="text-2xl font-bold text-white">{stats.completed_assignments}</div>
|
<StatsCard
|
||||||
<div className="text-sm text-gray-400">Заданий</div>
|
label="Заданий"
|
||||||
</div>
|
value={stats.completed_assignments}
|
||||||
<div className="bg-gray-900 rounded-lg p-4 text-center">
|
icon={<CheckCircle className="w-6 h-6" />}
|
||||||
<Flame className="w-6 h-6 text-orange-500 mx-auto mb-2" />
|
color="neon"
|
||||||
<div className="text-2xl font-bold text-white">{stats.total_points_earned}</div>
|
/>
|
||||||
<div className="text-sm text-gray-400">Очков</div>
|
<StatsCard
|
||||||
</div>
|
label="Очков"
|
||||||
|
value={stats.total_points_earned}
|
||||||
|
icon={<Flame className="w-6 h-6" />}
|
||||||
|
color="pink"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-400 text-center">Не удалось загрузить статистику</p>
|
<GlassCard className="text-center py-8">
|
||||||
|
<p className="text-gray-400">Не удалось загрузить статистику</p>
|
||||||
|
</GlassCard>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Telegram */}
|
{/* Telegram */}
|
||||||
<Card>
|
<GlassCard>
|
||||||
<CardHeader>
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center">
|
||||||
<MessageCircle className="w-5 h-5 text-blue-400" />
|
<MessageCircle className="w-6 h-6 text-blue-400" />
|
||||||
Telegram
|
</div>
|
||||||
</CardTitle>
|
<div>
|
||||||
</CardHeader>
|
<h2 className="text-lg font-semibold text-white">Telegram</h2>
|
||||||
<CardContent>
|
<p className="text-sm text-gray-400">
|
||||||
|
{isLinked ? 'Аккаунт привязан' : 'Привяжите для уведомлений'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLinked ? (
|
{isLinked ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-4 p-4 bg-gray-900 rounded-lg">
|
<div className="flex items-center gap-4 p-4 bg-dark-700/50 rounded-xl border border-dark-600">
|
||||||
<div className="w-12 h-12 rounded-full bg-blue-500/20 flex items-center justify-center overflow-hidden">
|
<div className="w-14 h-14 rounded-xl bg-blue-500/20 flex items-center justify-center overflow-hidden border border-blue-500/30">
|
||||||
{user?.telegram_avatar_url ? (
|
{user?.telegram_avatar_url ? (
|
||||||
<img
|
<img
|
||||||
src={user.telegram_avatar_url}
|
src={user.telegram_avatar_url}
|
||||||
@@ -386,7 +415,7 @@ export function ProfilePage() {
|
|||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Link2 className="w-6 h-6 text-blue-400" />
|
<Link2 className="w-7 h-7 text-blue-400" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -397,53 +426,61 @@ export function ProfilePage() {
|
|||||||
<p className="text-blue-400 text-sm">@{user.telegram_username}</p>
|
<p className="text-blue-400 text-sm">@{user.telegram_username}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<NeonButton
|
||||||
variant="danger"
|
variant="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleUnlinkTelegram}
|
onClick={handleUnlinkTelegram}
|
||||||
isLoading={telegramLoading}
|
isLoading={telegramLoading}
|
||||||
|
icon={<Link2Off className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
<Link2Off className="w-4 h-4 mr-2" />
|
|
||||||
Отвязать
|
Отвязать
|
||||||
</Button>
|
</NeonButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-gray-400">
|
<p className="text-gray-400">
|
||||||
Привяжи Telegram для получения уведомлений о событиях и марафонах.
|
Привяжите Telegram для получения уведомлений о событиях и марафонах.
|
||||||
</p>
|
</p>
|
||||||
{isPolling ? (
|
{isPolling ? (
|
||||||
<div className="p-4 bg-blue-500/20 border border-blue-500/50 rounded-lg">
|
<div className="p-4 bg-blue-500/10 border border-blue-500/30 rounded-xl">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
|
<Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
|
||||||
<p className="text-blue-400">Ожидание привязки...</p>
|
<p className="text-blue-400">Ожидание привязки...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Button onClick={handleLinkTelegram} isLoading={telegramLoading}>
|
<NeonButton
|
||||||
<ExternalLink className="w-4 h-4 mr-2" />
|
onClick={handleLinkTelegram}
|
||||||
|
isLoading={telegramLoading}
|
||||||
|
icon={<ExternalLink className="w-4 h-4" />}
|
||||||
|
>
|
||||||
Привязать Telegram
|
Привязать Telegram
|
||||||
</Button>
|
</NeonButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</GlassCard>
|
||||||
</Card>
|
|
||||||
|
{/* Security */}
|
||||||
|
<GlassCard>
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-accent-500/20 flex items-center justify-center">
|
||||||
|
<Shield className="w-6 h-6 text-accent-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Безопасность</h2>
|
||||||
|
<p className="text-sm text-gray-400">Управление паролем</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Смена пароля */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<KeyRound className="w-5 h-5 text-gray-400" />
|
|
||||||
Безопасность
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{!showPasswordForm ? (
|
{!showPasswordForm ? (
|
||||||
<Button variant="secondary" onClick={() => setShowPasswordForm(true)}>
|
<NeonButton
|
||||||
|
onClick={() => setShowPasswordForm(true)}
|
||||||
|
icon={<KeyRound className="w-4 h-4" />}
|
||||||
|
>
|
||||||
Сменить пароль
|
Сменить пароль
|
||||||
</Button>
|
</NeonButton>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={passwordForm.handleSubmit(onPasswordSubmit)} className="space-y-4">
|
<form onSubmit={passwordForm.handleSubmit(onPasswordSubmit)} className="space-y-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -456,7 +493,7 @@ export function ProfilePage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||||
className="absolute right-3 top-8 text-gray-400 hover:text-white"
|
className="absolute right-3 top-9 text-gray-400 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
{showCurrentPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
{showCurrentPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -472,7 +509,7 @@ export function ProfilePage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||||
className="absolute right-3 top-8 text-gray-400 hover:text-white"
|
className="absolute right-3 top-9 text-gray-400 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
{showNewPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
{showNewPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -485,11 +522,15 @@ export function ProfilePage() {
|
|||||||
error={passwordForm.formState.errors.confirm_password?.message}
|
error={passwordForm.formState.errors.confirm_password?.message}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-3">
|
||||||
<Button type="submit" isLoading={passwordForm.formState.isSubmitting}>
|
<NeonButton
|
||||||
|
type="submit"
|
||||||
|
isLoading={passwordForm.formState.isSubmitting}
|
||||||
|
icon={<Save className="w-4 h-4" />}
|
||||||
|
>
|
||||||
Сменить пароль
|
Сменить пароль
|
||||||
</Button>
|
</NeonButton>
|
||||||
<Button
|
<NeonButton
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -498,12 +539,11 @@ export function ProfilePage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</NeonButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</GlassCard>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { zodResolver } from '@hookform/resolvers/zod'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { marathonsApi } from '@/api'
|
import { marathonsApi } from '@/api'
|
||||||
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui'
|
import { NeonButton, Input, GlassCard } from '@/components/ui'
|
||||||
|
import { Gamepad2, UserPlus, AlertCircle, Trophy, Users, Zap, Target, Sparkles } from 'lucide-react'
|
||||||
|
|
||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
login: z
|
login: z
|
||||||
@@ -67,17 +68,106 @@ export function RegisterPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{ icon: <Trophy className="w-5 h-5" />, text: 'Соревнуйтесь с друзьями' },
|
||||||
|
{ icon: <Target className="w-5 h-5" />, text: 'Выполняйте челленджи' },
|
||||||
|
{ icon: <Zap className="w-5 h-5" />, text: 'Зарабатывайте очки' },
|
||||||
|
{ icon: <Users className="w-5 h-5" />, text: 'Создавайте марафоны' },
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto">
|
<div className="min-h-[80vh] flex items-center justify-center px-4 py-8">
|
||||||
<Card>
|
{/* Background effects */}
|
||||||
<CardHeader>
|
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||||
<CardTitle className="text-center">Регистрация</CardTitle>
|
<div className="absolute top-1/3 -right-32 w-96 h-96 bg-accent-500/10 rounded-full blur-[100px]" />
|
||||||
</CardHeader>
|
<div className="absolute bottom-1/3 -left-32 w-96 h-96 bg-neon-500/10 rounded-full blur-[100px]" />
|
||||||
<CardContent>
|
</div>
|
||||||
|
|
||||||
|
{/* Bento Grid */}
|
||||||
|
<div className="relative w-full max-w-4xl">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-scale-in">
|
||||||
|
{/* Branding Block (left) */}
|
||||||
|
<GlassCard className="p-8 flex flex-col justify-center relative overflow-hidden order-2 md:order-1" variant="neon">
|
||||||
|
{/* Background decoration */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<div className="absolute -top-20 -left-20 w-48 h-48 bg-accent-500/20 rounded-full blur-[60px]" />
|
||||||
|
<div className="absolute -bottom-20 -right-20 w-48 h-48 bg-neon-500/20 rounded-full blur-[60px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex justify-center md:justify-start mb-6">
|
||||||
|
<div className="w-20 h-20 rounded-2xl bg-accent-500/10 border border-accent-500/30 flex items-center justify-center shadow-[0_0_40px_rgba(147,51,234,0.3)]">
|
||||||
|
<Gamepad2 className="w-10 h-10 text-accent-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold text-white mb-2 text-center md:text-left">
|
||||||
|
Game Marathon
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 mb-6 text-center md:text-left">
|
||||||
|
Присоединяйтесь к игровому сообществу
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Benefits */}
|
||||||
|
<div className="p-4 rounded-xl bg-dark-700/50 border border-dark-600 mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Sparkles className="w-5 h-5 text-accent-400" />
|
||||||
|
<span className="text-white font-semibold">Что вас ждет:</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2 text-sm text-gray-400">
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-neon-500" />
|
||||||
|
Создавайте игровые марафоны
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-accent-500" />
|
||||||
|
Выполняйте уникальные челленджи
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-pink-500" />
|
||||||
|
Соревнуйтесь за первое место
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-2 p-3 rounded-xl bg-dark-700/50 border border-dark-600"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-accent-500/20 flex items-center justify-center text-accent-400">
|
||||||
|
{feature.icon}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-300">{feature.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
{/* Form Block (right) */}
|
||||||
|
<GlassCard className="p-8 order-1 md:order-2">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<div className="flex justify-center mb-4 md:hidden">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-accent-500/10 border border-accent-500/30 flex items-center justify-center">
|
||||||
|
<Gamepad2 className="w-8 h-8 text-accent-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-2">Создать аккаунт</h2>
|
||||||
|
<p className="text-gray-400">Начните играть уже сегодня</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
{(submitError || error) && (
|
{(submitError || error) && (
|
||||||
<div className="p-3 bg-red-500/20 border border-red-500 rounded-lg text-red-500 text-sm">
|
<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">
|
||||||
{submitError || error}
|
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span>{submitError || error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -85,12 +175,13 @@ export function RegisterPage() {
|
|||||||
label="Логин"
|
label="Логин"
|
||||||
placeholder="Придумайте логин"
|
placeholder="Придумайте логин"
|
||||||
error={errors.login?.message}
|
error={errors.login?.message}
|
||||||
|
autoComplete="username"
|
||||||
{...register('login')}
|
{...register('login')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="Никнейм"
|
label="Никнейм"
|
||||||
placeholder="Придумайте никнейм"
|
placeholder="Как вас называть?"
|
||||||
error={errors.nickname?.message}
|
error={errors.nickname?.message}
|
||||||
{...register('nickname')}
|
{...register('nickname')}
|
||||||
/>
|
/>
|
||||||
@@ -100,6 +191,7 @@ export function RegisterPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
placeholder="Придумайте пароль"
|
placeholder="Придумайте пароль"
|
||||||
error={errors.password?.message}
|
error={errors.password?.message}
|
||||||
|
autoComplete="new-password"
|
||||||
{...register('password')}
|
{...register('password')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -108,22 +200,41 @@ export function RegisterPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
placeholder="Повторите пароль"
|
placeholder="Повторите пароль"
|
||||||
error={errors.confirmPassword?.message}
|
error={errors.confirmPassword?.message}
|
||||||
|
autoComplete="new-password"
|
||||||
{...register('confirmPassword')}
|
{...register('confirmPassword')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" isLoading={isLoading}>
|
<NeonButton
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
|
color="purple"
|
||||||
|
isLoading={isLoading}
|
||||||
|
icon={<UserPlus className="w-5 h-5" />}
|
||||||
|
>
|
||||||
Зарегистрироваться
|
Зарегистрироваться
|
||||||
</Button>
|
</NeonButton>
|
||||||
|
</form>
|
||||||
|
|
||||||
<p className="text-center text-gray-400 text-sm">
|
{/* Footer */}
|
||||||
|
<div className="mt-6 pt-6 border-t border-dark-600 text-center">
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
Уже есть аккаунт?{' '}
|
Уже есть аккаунт?{' '}
|
||||||
<Link to="/login" className="link">
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="text-accent-400 hover:text-accent-300 transition-colors font-medium"
|
||||||
|
>
|
||||||
Войти
|
Войти
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</div>
|
||||||
</CardContent>
|
</GlassCard>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
|
{/* Decorative elements */}
|
||||||
|
<div className="absolute -top-4 -left-4 w-24 h-24 border border-accent-500/20 rounded-2xl -z-10 hidden md:block" />
|
||||||
|
<div className="absolute -bottom-4 -right-4 w-32 h-32 border border-neon-500/20 rounded-2xl -z-10 hidden md:block" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
143
frontend/src/pages/ServerErrorPage.tsx
Normal file
143
frontend/src/pages/ServerErrorPage.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { NeonButton } from '@/components/ui'
|
||||||
|
import { Home, Sparkles, RefreshCw, ServerCrash, Flame, Zap } from 'lucide-react'
|
||||||
|
|
||||||
|
export function ServerErrorPage() {
|
||||||
|
const handleRefresh = () => {
|
||||||
|
window.location.href = '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
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/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>
|
||||||
|
|
||||||
|
{/* Server icon */}
|
||||||
|
<div className="relative mb-8">
|
||||||
|
{/* Smoke/fire effect */}
|
||||||
|
<div className="absolute -top-6 left-1/2 -translate-x-1/2 flex gap-3">
|
||||||
|
<Flame className="w-6 h-6 text-orange-500/60 animate-flicker" style={{ animationDelay: '0s' }} />
|
||||||
|
<Flame className="w-5 h-5 text-red-500/50 animate-flicker" style={{ animationDelay: '0.2s' }} />
|
||||||
|
<Flame className="w-6 h-6 text-orange-500/60 animate-flicker" style={{ animationDelay: '0.4s' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server with error */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-32 h-32 rounded-2xl bg-dark-700/80 border-2 border-red-500/30 flex items-center justify-center shadow-[0_0_30px_rgba(239,68,68,0.2)]">
|
||||||
|
<ServerCrash className="w-16 h-16 text-red-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error indicator */}
|
||||||
|
<div className="absolute -bottom-2 -right-2 w-10 h-10 rounded-xl bg-red-500/20 border border-red-500/40 flex items-center justify-center animate-pulse">
|
||||||
|
<Zap className="w-5 h-5 text-red-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sparks */}
|
||||||
|
<div className="absolute top-2 -left-3 w-2 h-2 rounded-full bg-yellow-400 animate-spark" style={{ animationDelay: '0s' }} />
|
||||||
|
<div className="absolute top-6 -right-2 w-1.5 h-1.5 rounded-full bg-orange-400 animate-spark" style={{ animationDelay: '0.3s' }} />
|
||||||
|
<div className="absolute bottom-4 -left-2 w-1.5 h-1.5 rounded-full bg-red-400 animate-spark" style={{ animationDelay: '0.6s' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Glow effect */}
|
||||||
|
<div className="absolute inset-0 bg-red-500/20 rounded-full blur-3xl -z-10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 500 text */}
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<h1 className="text-8xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-red-400 via-orange-400 to-yellow-400">
|
||||||
|
500
|
||||||
|
</h1>
|
||||||
|
<div className="absolute inset-0 text-8xl font-bold text-red-500/20 blur-xl">
|
||||||
|
500
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-3">
|
||||||
|
Ошибка сервера
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-gray-400 mb-2 max-w-md">
|
||||||
|
Что-то пошло не так на нашей стороне.
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 text-sm mb-8 max-w-md">
|
||||||
|
Мы уже работаем над решением проблемы. Попробуйте обновить страницу.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Status info */}
|
||||||
|
<div className="glass rounded-xl p-4 mb-8 max-w-md border border-red-500/20">
|
||||||
|
<div className="flex items-center gap-2 text-red-400 mb-2">
|
||||||
|
<ServerCrash className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-semibold">Internal Server Error</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
|
Сервер временно недоступен или перегружен. Обычно это быстро исправляется.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<NeonButton
|
||||||
|
size="lg"
|
||||||
|
icon={<RefreshCw className="w-5 h-5" />}
|
||||||
|
onClick={handleRefresh}
|
||||||
|
>
|
||||||
|
Обновить
|
||||||
|
</NeonButton>
|
||||||
|
<Link to="/">
|
||||||
|
<NeonButton size="lg" variant="secondary" icon={<Home 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-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>
|
||||||
|
|
||||||
|
{/* Custom animations */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes flicker {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: translateY(-3px) scale(1.1);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-1px) scale(0.9);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translateY(-4px) scale(1.05);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-flicker {
|
||||||
|
animation: flicker 0.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes spark {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-spark {
|
||||||
|
animation: spark 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
107
frontend/src/pages/StaticContentPage.tsx
Normal file
107
frontend/src/pages/StaticContentPage.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams, useLocation, Link } from 'react-router-dom'
|
||||||
|
import { contentApi } from '@/api/admin'
|
||||||
|
import type { StaticContent } from '@/types'
|
||||||
|
import { GlassCard } from '@/components/ui'
|
||||||
|
import { ArrowLeft, Loader2, FileText } from 'lucide-react'
|
||||||
|
|
||||||
|
// Map routes to content keys
|
||||||
|
const ROUTE_KEY_MAP: Record<string, string> = {
|
||||||
|
'/terms': 'terms_of_service',
|
||||||
|
'/privacy': 'privacy_policy',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StaticContentPage() {
|
||||||
|
const { key: paramKey } = useParams<{ key: string }>()
|
||||||
|
const location = useLocation()
|
||||||
|
const [content, setContent] = useState<StaticContent | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Determine content key from route or param
|
||||||
|
const contentKey = ROUTE_KEY_MAP[location.pathname] || paramKey
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contentKey) return
|
||||||
|
|
||||||
|
const loadContent = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await contentApi.getPublicContent(contentKey)
|
||||||
|
setContent(data)
|
||||||
|
} catch {
|
||||||
|
setError('Контент не найден')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadContent()
|
||||||
|
}, [contentKey])
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-24">
|
||||||
|
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
|
||||||
|
<p className="text-gray-400">Загрузка...</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !content) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<GlassCard className="text-center py-16">
|
||||||
|
<div className="w-20 h-20 mx-auto mb-6 rounded-2xl bg-dark-700 flex items-center justify-center">
|
||||||
|
<FileText className="w-10 h-10 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-white mb-2">Страница не найдена</h3>
|
||||||
|
<p className="text-gray-400 mb-6">Запрашиваемый контент не существует</p>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="inline-flex items-center gap-2 text-neon-400 hover:text-neon-300 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
На главную
|
||||||
|
</Link>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="inline-flex items-center gap-2 text-gray-400 hover:text-neon-400 mb-6 transition-colors group"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" />
|
||||||
|
На главную
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<GlassCard>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-white mb-6">{content.title}</h1>
|
||||||
|
<div
|
||||||
|
className="prose prose-invert prose-gray max-w-none
|
||||||
|
prose-headings:text-white prose-headings:font-semibold
|
||||||
|
prose-p:text-gray-300 prose-p:leading-relaxed
|
||||||
|
prose-a:text-neon-400 prose-a:no-underline hover:prose-a:text-neon-300
|
||||||
|
prose-strong:text-white
|
||||||
|
prose-ul:text-gray-300 prose-ol:text-gray-300
|
||||||
|
prose-li:marker:text-gray-500
|
||||||
|
prose-hr:border-dark-600 prose-hr:my-6
|
||||||
|
prose-img:rounded-xl prose-img:shadow-lg"
|
||||||
|
dangerouslySetInnerHTML={{ __html: content.content }}
|
||||||
|
/>
|
||||||
|
<div className="mt-8 pt-6 border-t border-dark-600 text-sm text-gray-500">
|
||||||
|
Последнее обновление: {new Date(content.updated_at).toLocaleDateString('ru-RU', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
241
frontend/src/pages/TeapotPage.tsx
Normal file
241
frontend/src/pages/TeapotPage.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { NeonButton } from '@/components/ui'
|
||||||
|
import { Home, Sparkles, Coffee } from 'lucide-react'
|
||||||
|
|
||||||
|
export function TeapotPage() {
|
||||||
|
const [isPoured, setIsPoured] = useState(false)
|
||||||
|
|
||||||
|
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/4 -left-32 w-96 h-96 bg-amber-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>
|
||||||
|
|
||||||
|
{/* Teapot and Cup container */}
|
||||||
|
<div className="relative mb-8 flex items-start">
|
||||||
|
{/* Teapot */}
|
||||||
|
<div
|
||||||
|
className="relative cursor-pointer transition-transform duration-500 ease-out"
|
||||||
|
style={{
|
||||||
|
transform: isPoured ? 'rotate(15deg)' : 'rotate(0deg)',
|
||||||
|
transformOrigin: '80px 130px'
|
||||||
|
}}
|
||||||
|
onClick={() => setIsPoured(!isPoured)}
|
||||||
|
>
|
||||||
|
{/* Steam animation */}
|
||||||
|
<div className={`absolute -top-8 left-1/2 -translate-x-1/2 flex gap-2 transition-opacity duration-500 ${isPoured ? 'opacity-0' : 'opacity-50'}`}>
|
||||||
|
<div className="w-2 h-8 bg-gradient-to-t from-gray-400/50 to-transparent rounded-full animate-steam" style={{ animationDelay: '0s' }} />
|
||||||
|
<div className="w-2 h-10 bg-gradient-to-t from-gray-400/50 to-transparent rounded-full animate-steam" style={{ animationDelay: '0.3s' }} />
|
||||||
|
<div className="w-2 h-6 bg-gradient-to-t from-gray-400/50 to-transparent rounded-full animate-steam" style={{ animationDelay: '0.6s' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Teapot SVG - expanded viewBox to show full handle */}
|
||||||
|
<svg width="180" height="140" viewBox="-15 0 175 140" className="drop-shadow-2xl overflow-visible">
|
||||||
|
{/* Gradients */}
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="teapotGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#fde047" />
|
||||||
|
<stop offset="50%" stopColor="#fbbf24" />
|
||||||
|
<stop offset="100%" stopColor="#f59e0b" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="lidGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#fef08a" />
|
||||||
|
<stop offset="100%" stopColor="#fbbf24" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Handle - behind body */}
|
||||||
|
<path
|
||||||
|
d="M 25 70 Q -5 70 -5 90 Q -5 110 25 110"
|
||||||
|
fill="none"
|
||||||
|
stroke="#f59e0b"
|
||||||
|
strokeWidth="8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M 25 70 Q -5 70 -5 90 Q -5 110 25 110"
|
||||||
|
fill="none"
|
||||||
|
stroke="url(#teapotGradient)"
|
||||||
|
strokeWidth="5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<ellipse cx="80" cy="90" rx="55" ry="40" fill="url(#teapotGradient)" stroke="#f59e0b" strokeWidth="3" />
|
||||||
|
|
||||||
|
{/* Lid */}
|
||||||
|
<ellipse cx="80" cy="55" rx="35" ry="10" fill="url(#lidGradient)" stroke="#f59e0b" strokeWidth="2" />
|
||||||
|
<ellipse cx="80" cy="50" rx="25" ry="7" fill="url(#lidGradient)" stroke="#f59e0b" strokeWidth="2" />
|
||||||
|
<circle cx="80" cy="42" r="8" fill="#fbbf24" stroke="#f59e0b" strokeWidth="2" />
|
||||||
|
|
||||||
|
{/* Spout */}
|
||||||
|
<path
|
||||||
|
d="M 135 85 Q 150 75 155 60 Q 158 50 150 45"
|
||||||
|
fill="none"
|
||||||
|
stroke="#f59e0b"
|
||||||
|
strokeWidth="8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M 135 85 Q 150 75 155 60 Q 158 50 150 45"
|
||||||
|
fill="none"
|
||||||
|
stroke="url(#teapotGradient)"
|
||||||
|
strokeWidth="5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Face */}
|
||||||
|
<circle cx="65" cy="85" r="5" fill="#292524" />
|
||||||
|
<circle cx="95" cy="85" r="5" fill="#292524" />
|
||||||
|
<circle cx="67" cy="83" r="2" fill="white" />
|
||||||
|
<circle cx="97" cy="83" r="2" fill="white" />
|
||||||
|
<path d="M 70 100 Q 80 110 90 100" fill="none" stroke="#292524" strokeWidth="3" strokeLinecap="round" />
|
||||||
|
|
||||||
|
{/* Blush */}
|
||||||
|
<ellipse cx="55" cy="95" rx="8" ry="5" fill="#fca5a5" opacity="0.5" />
|
||||||
|
<ellipse cx="105" cy="95" rx="8" ry="5" fill="#fca5a5" opacity="0.5" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Glow effect */}
|
||||||
|
<div className="absolute inset-0 bg-amber-400/20 rounded-full blur-3xl -z-10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cup - positioned to the right and below */}
|
||||||
|
<div className="relative ml-[20px] mt-[125px]">
|
||||||
|
<svg width="100" height="70" viewBox="0 0 95 70" className="drop-shadow-xl">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="cupGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#fef3c7" />
|
||||||
|
<stop offset="100%" stopColor="#fde68a" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="teaInCupGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor="#d97706" />
|
||||||
|
<stop offset="100%" stopColor="#92400e" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Cup body */}
|
||||||
|
<path
|
||||||
|
d="M 10 15 L 15 60 Q 20 68 40 68 Q 60 68 65 60 L 70 15 Z"
|
||||||
|
fill="url(#cupGradient)"
|
||||||
|
stroke="#f59e0b"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Cup rim */}
|
||||||
|
<ellipse cx="40" cy="15" rx="30" ry="8" fill="url(#cupGradient)" stroke="#f59e0b" strokeWidth="2" />
|
||||||
|
|
||||||
|
{/* Tea in cup - fills up when pouring */}
|
||||||
|
<ellipse
|
||||||
|
cx="40"
|
||||||
|
cy="20"
|
||||||
|
rx="25"
|
||||||
|
ry="6"
|
||||||
|
fill="url(#teaInCupGradient)"
|
||||||
|
className={`transition-all duration-1000 ${isPoured ? 'opacity-100' : 'opacity-30'}`}
|
||||||
|
style={{
|
||||||
|
transform: isPoured ? 'translateY(0)' : 'translateY(15px)',
|
||||||
|
transformOrigin: 'center'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Handle */}
|
||||||
|
<path
|
||||||
|
d="M 70 25 Q 85 25 85 40 Q 85 55 70 55"
|
||||||
|
fill="none"
|
||||||
|
stroke="#f59e0b"
|
||||||
|
strokeWidth="5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M 70 25 Q 85 25 85 40 Q 85 55 70 55"
|
||||||
|
fill="none"
|
||||||
|
stroke="url(#cupGradient)"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Steam from cup when filled */}
|
||||||
|
<div className={`absolute -top-4 left-1/2 -translate-x-1/2 flex gap-1 transition-opacity duration-1000 ${isPoured ? 'opacity-60' : 'opacity-0'}`}>
|
||||||
|
<div className="w-1 h-4 bg-gradient-to-t from-gray-400/40 to-transparent rounded-full animate-steam" style={{ animationDelay: '0.5s' }} />
|
||||||
|
<div className="w-1 h-5 bg-gradient-to-t from-gray-400/40 to-transparent rounded-full animate-steam" style={{ animationDelay: '0.8s' }} />
|
||||||
|
<div className="w-1 h-3 bg-gradient-to-t from-gray-400/40 to-transparent rounded-full animate-steam" style={{ animationDelay: '1.1s' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 418 text */}
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<h1 className="text-8xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 via-orange-400 to-red-400">
|
||||||
|
418
|
||||||
|
</h1>
|
||||||
|
<div className="absolute inset-0 text-8xl font-bold text-amber-500/20 blur-xl">
|
||||||
|
418
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-3">
|
||||||
|
I'm a teapot
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-gray-400 mb-2 max-w-md">
|
||||||
|
Сервер отказывается варить кофе, потому что он чайник.
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 text-sm mb-8 max-w-md">
|
||||||
|
RFC 2324, Hyper Text Coffee Pot Control Protocol
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Fun fact */}
|
||||||
|
<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">
|
||||||
|
<Coffee className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-semibold">Fun fact</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
|
Это настоящий HTTP-код ответа из первоапрельской шутки 1998 года.
|
||||||
|
Нажми на чайник!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Button */}
|
||||||
|
<Link to="/">
|
||||||
|
<NeonButton size="lg" icon={<Home className="w-5 h-5" />}>
|
||||||
|
На главную
|
||||||
|
</NeonButton>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* 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-orange-400 animate-pulse" style={{ animationDelay: '1s' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom animations */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes steam {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0) scaleX(1);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-10px) scaleX(1.2);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(-20px) scaleX(0.8);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-steam {
|
||||||
|
animation: steam 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,10 +3,10 @@ import { useParams, useNavigate, Link } from 'react-router-dom'
|
|||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { usersApi } from '@/api'
|
import { usersApi } from '@/api'
|
||||||
import type { UserProfilePublic } from '@/types'
|
import type { UserProfilePublic } from '@/types'
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui'
|
import { GlassCard, StatsCard } from '@/components/ui'
|
||||||
import {
|
import {
|
||||||
User, Trophy, Target, CheckCircle, Flame,
|
User, Trophy, Target, CheckCircle, Flame,
|
||||||
Loader2, ArrowLeft, Calendar
|
Loader2, ArrowLeft, Calendar, Zap
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
export function UserProfilePage() {
|
export function UserProfilePage() {
|
||||||
@@ -82,8 +82,9 @@ export function UserProfilePage() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center py-12">
|
<div className="flex flex-col items-center justify-center py-24">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
|
<Loader2 className="w-12 h-12 animate-spin text-neon-500 mb-4" />
|
||||||
|
<p className="text-gray-400">Загрузка профиля...</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -91,17 +92,17 @@ export function UserProfilePage() {
|
|||||||
if (error || !profile) {
|
if (error || !profile) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
<Card>
|
<GlassCard className="py-12 text-center">
|
||||||
<CardContent className="py-12 text-center">
|
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-dark-700 flex items-center justify-center">
|
||||||
<User className="w-16 h-16 text-gray-600 mx-auto mb-4" />
|
<User className="w-10 h-10 text-gray-600" />
|
||||||
|
</div>
|
||||||
<h2 className="text-xl font-bold text-white mb-2">
|
<h2 className="text-xl font-bold text-white mb-2">
|
||||||
{error || 'Пользователь не найден'}
|
{error || 'Пользователь не найден'}
|
||||||
</h2>
|
</h2>
|
||||||
<Link to="/" className="text-primary-400 hover:text-primary-300">
|
<Link to="/" className="text-neon-400 hover:text-neon-300 transition-colors">
|
||||||
Вернуться на главную
|
Вернуться на главную
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
</GlassCard>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -111,18 +112,18 @@ export function UserProfilePage() {
|
|||||||
{/* Кнопка назад */}
|
{/* Кнопка назад */}
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(-1)}
|
||||||
className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
|
className="flex items-center gap-2 text-gray-400 hover:text-neon-400 transition-colors group"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-5 h-5" />
|
<ArrowLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
|
||||||
Назад
|
Назад
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Профиль */}
|
{/* Профиль */}
|
||||||
<Card>
|
<GlassCard variant="neon">
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
{/* Аватар */}
|
{/* Аватар */}
|
||||||
<div className="w-24 h-24 rounded-full overflow-hidden bg-gray-700 flex-shrink-0">
|
<div className="relative">
|
||||||
|
<div className="w-24 h-24 rounded-2xl overflow-hidden bg-dark-700 border-2 border-neon-500/30 shadow-[0_0_14px_rgba(34,211,238,0.15)]">
|
||||||
{avatarBlobUrl ? (
|
{avatarBlobUrl ? (
|
||||||
<img
|
<img
|
||||||
src={avatarBlobUrl}
|
src={avatarBlobUrl}
|
||||||
@@ -130,11 +131,16 @@ export function UserProfilePage() {
|
|||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-dark-700 to-dark-800">
|
||||||
<User className="w-12 h-12 text-gray-500" />
|
<User className="w-12 h-12 text-gray-500" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Online indicator effect */}
|
||||||
|
<div className="absolute -bottom-1 -right-1 w-6 h-6 rounded-lg bg-neon-500/20 border border-neon-500/30 flex items-center justify-center">
|
||||||
|
<Zap className="w-3 h-3 text-neon-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Инфо */}
|
{/* Инфо */}
|
||||||
<div>
|
<div>
|
||||||
@@ -142,55 +148,52 @@ export function UserProfilePage() {
|
|||||||
{profile.nickname}
|
{profile.nickname}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-2 text-gray-400 text-sm">
|
<div className="flex items-center gap-2 text-gray-400 text-sm">
|
||||||
<Calendar className="w-4 h-4" />
|
<Calendar className="w-4 h-4 text-accent-400" />
|
||||||
<span>Зарегистрирован {formatDate(profile.created_at)}</span>
|
<span>Зарегистрирован {formatDate(profile.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</GlassCard>
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Статистика */}
|
{/* Статистика */}
|
||||||
<Card>
|
<GlassCard>
|
||||||
<CardHeader>
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center">
|
||||||
<Trophy className="w-5 h-5 text-yellow-500" />
|
<Trophy className="w-5 h-5 text-yellow-400" />
|
||||||
Статистика
|
</div>
|
||||||
</CardTitle>
|
<div>
|
||||||
</CardHeader>
|
<h2 className="font-semibold text-white">Статистика</h2>
|
||||||
<CardContent>
|
<p className="text-sm text-gray-400">Достижения игрока</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div className="bg-gray-900 rounded-lg p-4 text-center">
|
<StatsCard
|
||||||
<Target className="w-6 h-6 text-blue-400 mx-auto mb-2" />
|
label="Марафонов"
|
||||||
<div className="text-2xl font-bold text-white">
|
value={profile.stats.marathons_count}
|
||||||
{profile.stats.marathons_count}
|
icon={<Target className="w-6 h-6" />}
|
||||||
|
color="neon"
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="Побед"
|
||||||
|
value={profile.stats.wins_count}
|
||||||
|
icon={<Trophy className="w-6 h-6" />}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="Заданий"
|
||||||
|
value={profile.stats.completed_assignments}
|
||||||
|
icon={<CheckCircle className="w-6 h-6" />}
|
||||||
|
color="default"
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
label="Очков"
|
||||||
|
value={profile.stats.total_points_earned}
|
||||||
|
icon={<Flame className="w-6 h-6" />}
|
||||||
|
color="pink"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-400">Марафонов</div>
|
</GlassCard>
|
||||||
</div>
|
|
||||||
<div className="bg-gray-900 rounded-lg p-4 text-center">
|
|
||||||
<Trophy className="w-6 h-6 text-yellow-500 mx-auto mb-2" />
|
|
||||||
<div className="text-2xl font-bold text-white">
|
|
||||||
{profile.stats.wins_count}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-400">Побед</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-900 rounded-lg p-4 text-center">
|
|
||||||
<CheckCircle className="w-6 h-6 text-green-500 mx-auto mb-2" />
|
|
||||||
<div className="text-2xl font-bold text-white">
|
|
||||||
{profile.stats.completed_assignments}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-400">Заданий</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gray-900 rounded-lg p-4 text-center">
|
|
||||||
<Flame className="w-6 h-6 text-orange-500 mx-auto mb-2" />
|
|
||||||
<div className="text-2xl font-bold text-white">
|
|
||||||
{profile.stats.total_points_earned}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-400">Очков</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
190
frontend/src/pages/admin/AdminBroadcastPage.tsx
Normal file
190
frontend/src/pages/admin/AdminBroadcastPage.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { adminApi } from '@/api'
|
||||||
|
import type { AdminMarathon } from '@/types'
|
||||||
|
import { useToast } from '@/store/toast'
|
||||||
|
import { NeonButton } from '@/components/ui'
|
||||||
|
import { Send, Users, Trophy, AlertTriangle } from 'lucide-react'
|
||||||
|
|
||||||
|
export function AdminBroadcastPage() {
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [targetType, setTargetType] = useState<'all' | 'marathon'>('all')
|
||||||
|
const [marathonId, setMarathonId] = useState<number | null>(null)
|
||||||
|
const [marathons, setMarathons] = useState<AdminMarathon[]>([])
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
const [loadingMarathons, setLoadingMarathons] = useState(false)
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (targetType === 'marathon') {
|
||||||
|
loadMarathons()
|
||||||
|
}
|
||||||
|
}, [targetType])
|
||||||
|
|
||||||
|
const loadMarathons = async () => {
|
||||||
|
setLoadingMarathons(true)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.listMarathons(0, 100)
|
||||||
|
setMarathons(data.filter(m => m.status === 'active'))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load marathons:', err)
|
||||||
|
} finally {
|
||||||
|
setLoadingMarathons(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!message.trim()) {
|
||||||
|
toast.error('Введите сообщение')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetType === 'marathon' && !marathonId) {
|
||||||
|
toast.error('Выберите марафон')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSending(true)
|
||||||
|
try {
|
||||||
|
let result
|
||||||
|
if (targetType === 'all') {
|
||||||
|
result = await adminApi.broadcastToAll(message)
|
||||||
|
} else {
|
||||||
|
result = await adminApi.broadcastToMarathon(marathonId!, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(`Отправлено ${result.sent_count} из ${result.total_count} сообщений`)
|
||||||
|
setMessage('')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to send broadcast:', err)
|
||||||
|
toast.error('Ошибка отправки')
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-pink-500/20 border border-pink-500/30">
|
||||||
|
<Send className="w-6 h-6 text-pink-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Рассылка уведомлений</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-2xl space-y-6">
|
||||||
|
{/* Target Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="block text-sm font-medium text-gray-300">
|
||||||
|
Кому отправить
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTargetType('all')
|
||||||
|
setMarathonId(null)
|
||||||
|
}}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-xl border transition-all duration-200 ${
|
||||||
|
targetType === 'all'
|
||||||
|
? 'bg-accent-500/20 border-accent-500/50 text-accent-400 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
|
||||||
|
: 'bg-dark-700/50 border-dark-600 text-gray-400 hover:border-dark-500 hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Users className="w-5 h-5" />
|
||||||
|
<span className="font-medium">Всем пользователям</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTargetType('marathon')}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-xl border transition-all duration-200 ${
|
||||||
|
targetType === 'marathon'
|
||||||
|
? 'bg-accent-500/20 border-accent-500/50 text-accent-400 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
|
||||||
|
: 'bg-dark-700/50 border-dark-600 text-gray-400 hover:border-dark-500 hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Trophy className="w-5 h-5" />
|
||||||
|
<span className="font-medium">Участникам марафона</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Marathon Selection */}
|
||||||
|
{targetType === 'marathon' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-300">
|
||||||
|
Выберите марафон
|
||||||
|
</label>
|
||||||
|
{loadingMarathons ? (
|
||||||
|
<div className="animate-pulse bg-dark-700 h-12 rounded-xl" />
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={marathonId || ''}
|
||||||
|
onChange={(e) => setMarathonId(Number(e.target.value) || null)}
|
||||||
|
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="">Выберите марафон...</option>
|
||||||
|
{marathons.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.title} ({m.participants_count} участников)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{marathons.length === 0 && !loadingMarathons && (
|
||||||
|
<p className="text-sm text-gray-500">Нет активных марафонов</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-300">
|
||||||
|
Сообщение
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
rows={6}
|
||||||
|
placeholder="Введите текст сообщения... (поддерживается HTML: <b>, <i>, <code>)"
|
||||||
|
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors resize-none"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<p className="text-gray-500">
|
||||||
|
Поддерживается HTML: <b>, <i>, <code>, <a href>
|
||||||
|
</p>
|
||||||
|
<p className={`${message.length > 2000 ? 'text-red-400' : 'text-gray-500'}`}>
|
||||||
|
{message.length} / 2000
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Send Button */}
|
||||||
|
<NeonButton
|
||||||
|
size="lg"
|
||||||
|
color="purple"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={sending || !message.trim() || message.length > 2000 || (targetType === 'marathon' && !marathonId)}
|
||||||
|
isLoading={sending}
|
||||||
|
icon={<Send className="w-5 h-5" />}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{sending ? 'Отправка...' : 'Отправить рассылку'}
|
||||||
|
</NeonButton>
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
<div className="glass rounded-xl p-4 border border-amber-500/20">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-amber-400 font-medium mb-1">Обратите внимание</p>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Сообщение будет отправлено только пользователям с привязанным Telegram.
|
||||||
|
Рассылка ограничена: 1 сообщение всем в минуту, 3 сообщения марафону в минуту.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
300
frontend/src/pages/admin/AdminContentPage.tsx
Normal file
300
frontend/src/pages/admin/AdminContentPage.tsx
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { adminApi } from '@/api'
|
||||||
|
import type { StaticContent } from '@/types'
|
||||||
|
import { useToast } from '@/store/toast'
|
||||||
|
import { NeonButton } from '@/components/ui'
|
||||||
|
import { FileText, Plus, Pencil, X, Save, Code, Trash2 } from 'lucide-react'
|
||||||
|
import { useConfirm } from '@/store/confirm'
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminContentPage() {
|
||||||
|
const [contents, setContents] = useState<StaticContent[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [editing, setEditing] = useState<StaticContent | null>(null)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [formKey, setFormKey] = useState('')
|
||||||
|
const [formTitle, setFormTitle] = useState('')
|
||||||
|
const [formContent, setFormContent] = useState('')
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadContents()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadContents = async () => {
|
||||||
|
try {
|
||||||
|
const data = await adminApi.listContent()
|
||||||
|
setContents(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load contents:', err)
|
||||||
|
toast.error('Ошибка загрузки контента')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEdit = (content: StaticContent) => {
|
||||||
|
setEditing(content)
|
||||||
|
setFormKey(content.key)
|
||||||
|
setFormTitle(content.title)
|
||||||
|
setFormContent(content.content)
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setCreating(true)
|
||||||
|
setEditing(null)
|
||||||
|
setFormKey('')
|
||||||
|
setFormTitle('')
|
||||||
|
setFormContent('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setEditing(null)
|
||||||
|
setCreating(false)
|
||||||
|
setFormKey('')
|
||||||
|
setFormTitle('')
|
||||||
|
setFormContent('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formTitle.trim() || !formContent.trim()) {
|
||||||
|
toast.error('Заполните все поля')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (creating && !formKey.trim()) {
|
||||||
|
toast.error('Введите ключ')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
if (creating) {
|
||||||
|
const newContent = await adminApi.createContent(formKey, formTitle, formContent)
|
||||||
|
setContents([...contents, newContent])
|
||||||
|
toast.success('Контент создан')
|
||||||
|
} else if (editing) {
|
||||||
|
const updated = await adminApi.updateContent(editing.key, formTitle, formContent)
|
||||||
|
setContents(contents.map(c => c.id === updated.id ? updated : c))
|
||||||
|
toast.success('Контент обновлён')
|
||||||
|
}
|
||||||
|
handleCancel()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save content:', err)
|
||||||
|
toast.error('Ошибка сохранения')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (content: StaticContent) => {
|
||||||
|
const confirmed = await confirm({
|
||||||
|
title: 'Удалить контент?',
|
||||||
|
message: `Вы уверены, что хотите удалить "${content.title}"? Это действие нельзя отменить.`,
|
||||||
|
confirmText: 'Удалить',
|
||||||
|
cancelText: 'Отмена',
|
||||||
|
variant: 'danger',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await adminApi.deleteContent(content.key)
|
||||||
|
setContents(contents.filter(c => c.id !== content.id))
|
||||||
|
if (editing?.id === content.id) {
|
||||||
|
handleCancel()
|
||||||
|
}
|
||||||
|
toast.success('Контент удалён')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete content:', err)
|
||||||
|
toast.error('Ошибка удаления')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-2 border-accent-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-neon-500/20 border border-neon-500/30">
|
||||||
|
<FileText className="w-6 h-6 text-neon-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Статический контент</h1>
|
||||||
|
</div>
|
||||||
|
<NeonButton onClick={handleCreate} icon={<Plus className="w-4 h-4" />}>
|
||||||
|
Добавить
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Content List */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{contents.length === 0 ? (
|
||||||
|
<div className="glass rounded-xl border border-dark-600 p-8 text-center">
|
||||||
|
<FileText className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-400">Нет статического контента</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Создайте первую страницу</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
contents.map((content) => (
|
||||||
|
<div
|
||||||
|
key={content.id}
|
||||||
|
className={`glass rounded-xl border p-5 cursor-pointer transition-all duration-200 ${
|
||||||
|
editing?.id === content.id
|
||||||
|
? 'border-accent-500/50 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
|
||||||
|
: 'border-dark-600 hover:border-dark-500'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleEdit(content)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Code className="w-4 h-4 text-neon-400" />
|
||||||
|
<p className="text-sm text-neon-400 font-mono">{content.key}</p>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-white truncate">{content.title}</h3>
|
||||||
|
<p className="text-sm text-gray-400 mt-2 line-clamp-2">
|
||||||
|
{content.content.replace(/<[^>]*>/g, '').slice(0, 100)}...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 ml-3">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleEdit(content)
|
||||||
|
}}
|
||||||
|
className="p-2 text-gray-400 hover:text-accent-400 hover:bg-accent-500/10 rounded-lg transition-colors"
|
||||||
|
title="Редактировать"
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDelete(content)
|
||||||
|
}}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-400 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-4 pt-3 border-t border-dark-600">
|
||||||
|
Обновлено: {formatDate(content.updated_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Editor */}
|
||||||
|
{(editing || creating) && (
|
||||||
|
<div className="glass rounded-xl border border-dark-600 p-6 sticky top-6 h-fit">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
{creating ? (
|
||||||
|
<>
|
||||||
|
<Plus className="w-5 h-5 text-neon-400" />
|
||||||
|
Новый контент
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Pencil className="w-5 h-5 text-accent-400" />
|
||||||
|
Редактирование
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="p-2 text-gray-400 hover:text-white hover:bg-dark-600/50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{creating && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Ключ
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formKey}
|
||||||
|
onChange={(e) => setFormKey(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ''))}
|
||||||
|
placeholder="about-page"
|
||||||
|
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white font-mono placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1.5">
|
||||||
|
Только буквы, цифры, дефисы и подчеркивания
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Заголовок
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formTitle}
|
||||||
|
onChange={(e) => setFormTitle(e.target.value)}
|
||||||
|
placeholder="Заголовок страницы"
|
||||||
|
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
|
Содержимое (HTML)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={formContent}
|
||||||
|
onChange={(e) => setFormContent(e.target.value)}
|
||||||
|
rows={14}
|
||||||
|
placeholder="<p>HTML контент...</p>"
|
||||||
|
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 font-mono text-sm resize-none transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NeonButton
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
isLoading={saving}
|
||||||
|
icon={<Save className="w-4 h-4" />}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</NeonButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
207
frontend/src/pages/admin/AdminDashboardPage.tsx
Normal file
207
frontend/src/pages/admin/AdminDashboardPage.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { adminApi } from '@/api'
|
||||||
|
import type { DashboardStats } from '@/types'
|
||||||
|
import { Users, Trophy, Gamepad2, UserCheck, Ban, Activity, TrendingUp } from 'lucide-react'
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
user_ban: 'Бан пользователя',
|
||||||
|
user_unban: 'Разбан пользователя',
|
||||||
|
user_role_change: 'Изменение роли',
|
||||||
|
marathon_force_finish: 'Принудительное завершение',
|
||||||
|
marathon_delete: 'Удаление марафона',
|
||||||
|
content_update: 'Обновление контента',
|
||||||
|
broadcast_all: 'Рассылка всем',
|
||||||
|
broadcast_marathon: 'Рассылка марафону',
|
||||||
|
admin_login: 'Вход админа',
|
||||||
|
admin_2fa_success: '2FA успех',
|
||||||
|
admin_2fa_fail: '2FA неудача',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_COLORS: Record<string, string> = {
|
||||||
|
user_ban: 'text-red-400',
|
||||||
|
user_unban: 'text-green-400',
|
||||||
|
user_role_change: 'text-accent-400',
|
||||||
|
marathon_force_finish: 'text-orange-400',
|
||||||
|
marathon_delete: 'text-red-400',
|
||||||
|
content_update: 'text-neon-400',
|
||||||
|
broadcast_all: 'text-pink-400',
|
||||||
|
broadcast_marathon: 'text-pink-400',
|
||||||
|
admin_login: 'text-blue-400',
|
||||||
|
admin_2fa_success: 'text-green-400',
|
||||||
|
admin_2fa_fail: 'text-red-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
gradient,
|
||||||
|
glowColor
|
||||||
|
}: {
|
||||||
|
icon: typeof Users
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
gradient: string
|
||||||
|
glowColor: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`glass rounded-xl p-5 border border-dark-600 hover:border-dark-500 transition-all duration-300 hover:shadow-[0_0_20px_${glowColor}]`}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={`p-3 rounded-xl ${gradient} shadow-lg`}>
|
||||||
|
<Icon className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-400">{label}</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{value.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminDashboardPage() {
|
||||||
|
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDashboard()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadDashboard = async () => {
|
||||||
|
try {
|
||||||
|
const data = await adminApi.getDashboard()
|
||||||
|
setStats(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load dashboard:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-2 border-accent-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats) {
|
||||||
|
return (
|
||||||
|
<div className="text-center text-gray-400 py-12">
|
||||||
|
Не удалось загрузить данные
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-accent-500/20 border border-accent-500/30">
|
||||||
|
<TrendingUp className="w-6 h-6 text-accent-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Дашборд</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<StatCard
|
||||||
|
icon={Users}
|
||||||
|
label="Всего пользователей"
|
||||||
|
value={stats.users_count}
|
||||||
|
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
|
||||||
|
glowColor="rgba(59,130,246,0.15)"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Ban}
|
||||||
|
label="Заблокировано"
|
||||||
|
value={stats.banned_users_count}
|
||||||
|
gradient="bg-gradient-to-br from-red-500 to-red-600"
|
||||||
|
glowColor="rgba(239,68,68,0.15)"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Trophy}
|
||||||
|
label="Всего марафонов"
|
||||||
|
value={stats.marathons_count}
|
||||||
|
gradient="bg-gradient-to-br from-accent-500 to-pink-500"
|
||||||
|
glowColor="rgba(139,92,246,0.15)"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Activity}
|
||||||
|
label="Активных марафонов"
|
||||||
|
value={stats.active_marathons_count}
|
||||||
|
gradient="bg-gradient-to-br from-green-500 to-emerald-600"
|
||||||
|
glowColor="rgba(34,197,94,0.15)"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Gamepad2}
|
||||||
|
label="Всего игр"
|
||||||
|
value={stats.games_count}
|
||||||
|
gradient="bg-gradient-to-br from-orange-500 to-amber-500"
|
||||||
|
glowColor="rgba(249,115,22,0.15)"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={UserCheck}
|
||||||
|
label="Участий в марафонах"
|
||||||
|
value={stats.total_participations}
|
||||||
|
gradient="bg-gradient-to-br from-neon-500 to-cyan-500"
|
||||||
|
glowColor="rgba(34,211,238,0.15)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Logs */}
|
||||||
|
<div className="glass rounded-xl border border-dark-600 overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-dark-600">
|
||||||
|
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<Activity className="w-5 h-5 text-accent-400" />
|
||||||
|
Последние действия
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
{stats.recent_logs.length === 0 ? (
|
||||||
|
<p className="text-gray-400 text-center py-4">Нет записей</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{stats.recent_logs.map((log) => (
|
||||||
|
<div
|
||||||
|
key={log.id}
|
||||||
|
className="flex items-start justify-between p-4 bg-dark-700/50 hover:bg-dark-700 rounded-xl border border-dark-600 transition-colors"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className={`font-medium ${ACTION_COLORS[log.action] || 'text-white'}`}>
|
||||||
|
{ACTION_LABELS[log.action] || log.action}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
<span className="text-gray-500">Админ:</span> {log.admin_nickname}
|
||||||
|
<span className="text-gray-600 mx-2">•</span>
|
||||||
|
<span className="text-gray-500">{log.target_type}</span> #{log.target_id}
|
||||||
|
</p>
|
||||||
|
{log.details && (
|
||||||
|
<p className="text-xs text-gray-500 mt-2 font-mono bg-dark-800 rounded px-2 py-1 inline-block">
|
||||||
|
{JSON.stringify(log.details)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500 whitespace-nowrap ml-4">
|
||||||
|
{formatDate(log.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
169
frontend/src/pages/admin/AdminLayout.tsx
Normal file
169
frontend/src/pages/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { Outlet, NavLink, Navigate, Link } from 'react-router-dom'
|
||||||
|
import { useAuthStore } from '@/store/auth'
|
||||||
|
import { NeonButton } from '@/components/ui'
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
Trophy,
|
||||||
|
ScrollText,
|
||||||
|
Send,
|
||||||
|
FileText,
|
||||||
|
ArrowLeft,
|
||||||
|
Shield,
|
||||||
|
MessageCircle,
|
||||||
|
Sparkles,
|
||||||
|
Lock
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: '/admin', icon: LayoutDashboard, label: 'Дашборд', end: true },
|
||||||
|
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
|
||||||
|
{ to: '/admin/marathons', icon: Trophy, label: 'Марафоны' },
|
||||||
|
{ to: '/admin/logs', icon: ScrollText, label: 'Логи' },
|
||||||
|
{ to: '/admin/broadcast', icon: Send, label: 'Рассылка' },
|
||||||
|
{ to: '/admin/content', icon: FileText, label: 'Контент' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AdminLayout() {
|
||||||
|
const user = useAuthStore((state) => state.user)
|
||||||
|
|
||||||
|
// Only admins can access
|
||||||
|
if (!user || user.role !== 'admin') {
|
||||||
|
return <Navigate to="/" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin without Telegram - show warning
|
||||||
|
if (!user.telegram_id) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[70vh] flex flex-col items-center justify-center text-center px-4">
|
||||||
|
{/* Background effects */}
|
||||||
|
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<div className="absolute top-1/3 -left-32 w-96 h-96 bg-amber-500/5 rounded-full blur-[100px]" />
|
||||||
|
<div className="absolute bottom-1/3 -right-32 w-96 h-96 bg-accent-500/5 rounded-full blur-[100px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="relative mb-8 animate-float">
|
||||||
|
<div className="w-32 h-32 rounded-3xl bg-dark-700/50 border border-amber-500/30 flex items-center justify-center shadow-[0_0_30px_rgba(245,158,11,0.15)]">
|
||||||
|
<Lock className="w-16 h-16 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute -bottom-2 -right-2 w-12 h-12 rounded-xl bg-accent-500/20 border border-accent-500/30 flex items-center justify-center">
|
||||||
|
<Shield className="w-6 h-6 text-accent-400" />
|
||||||
|
</div>
|
||||||
|
{/* Decorative dots */}
|
||||||
|
<div className="absolute -top-2 -left-2 w-3 h-3 rounded-full bg-amber-500/50 animate-pulse" />
|
||||||
|
<div className="absolute top-4 -right-4 w-2 h-2 rounded-full bg-accent-500/50 animate-pulse" style={{ animationDelay: '0.5s' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title with glow */}
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<h1 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-amber-400 via-orange-400 to-accent-400">
|
||||||
|
Требуется Telegram
|
||||||
|
</h1>
|
||||||
|
<div className="absolute inset-0 text-3xl font-bold text-amber-500/20 blur-xl">
|
||||||
|
Требуется Telegram
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-400 mb-2 max-w-md">
|
||||||
|
Для доступа к админ-панели необходимо привязать Telegram-аккаунт.
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 text-sm mb-8 max-w-md">
|
||||||
|
Это требуется для двухфакторной аутентификации при входе.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Info card */}
|
||||||
|
<div className="glass rounded-xl p-4 mb-8 max-w-md border border-amber-500/20">
|
||||||
|
<div className="flex items-center gap-2 text-amber-400 mb-2">
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-semibold">Двухфакторная аутентификация</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
|
После привязки Telegram при входе в админ-панель вам будет отправляться код подтверждения.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Link to="/profile">
|
||||||
|
<NeonButton size="lg" color="purple" icon={<MessageCircle className="w-5 h-5" />}>
|
||||||
|
Привязать Telegram
|
||||||
|
</NeonButton>
|
||||||
|
</Link>
|
||||||
|
<Link to="/marathons">
|
||||||
|
<NeonButton size="lg" variant="secondary" icon={<ArrowLeft className="w-5 h-5" />}>
|
||||||
|
На сайт
|
||||||
|
</NeonButton>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decorative sparkles */}
|
||||||
|
<div className="absolute top-1/4 left-1/4 opacity-20">
|
||||||
|
<Sparkles className="w-6 h-6 text-amber-400 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-1/3 right-1/4 opacity-20">
|
||||||
|
<Sparkles className="w-4 h-4 text-accent-400 animate-pulse" style={{ animationDelay: '1s' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-[calc(100vh-64px)]">
|
||||||
|
{/* Background effects */}
|
||||||
|
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<div className="absolute top-0 -left-32 w-96 h-96 bg-accent-500/5 rounded-full blur-[100px]" />
|
||||||
|
<div className="absolute bottom-0 -right-32 w-96 h-96 bg-neon-500/5 rounded-full blur-[100px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-64 glass border-r border-dark-600 flex flex-col relative z-10">
|
||||||
|
<div className="p-4 border-b border-dark-600">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-accent-500 to-pink-500 flex items-center justify-center">
|
||||||
|
<Shield className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-bold text-transparent bg-clip-text bg-gradient-to-r from-accent-400 to-pink-400">
|
||||||
|
Админ-панель
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 p-4 space-y-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.end}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30 shadow-[0_0_10px_rgba(139,92,246,0.15)]'
|
||||||
|
: 'text-gray-400 hover:bg-dark-600/50 hover:text-white border border-transparent'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="w-5 h-5" />
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-dark-600">
|
||||||
|
<NavLink
|
||||||
|
to="/marathons"
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 text-gray-400 hover:text-neon-400 transition-colors rounded-lg hover:bg-dark-600/50"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
<span className="font-medium">Вернуться на сайт</span>
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1 p-6 overflow-auto relative z-10">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
208
frontend/src/pages/admin/AdminLogsPage.tsx
Normal file
208
frontend/src/pages/admin/AdminLogsPage.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { adminApi } from '@/api'
|
||||||
|
import type { AdminLog } from '@/types'
|
||||||
|
import { useToast } from '@/store/toast'
|
||||||
|
import { ChevronLeft, ChevronRight, Filter, ScrollText } from 'lucide-react'
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
user_ban: 'Бан пользователя',
|
||||||
|
user_unban: 'Разбан пользователя',
|
||||||
|
user_auto_unban: 'Авто-разбан (система)',
|
||||||
|
user_role_change: 'Изменение роли',
|
||||||
|
marathon_force_finish: 'Принудительное завершение',
|
||||||
|
marathon_delete: 'Удаление марафона',
|
||||||
|
content_update: 'Обновление контента',
|
||||||
|
broadcast_all: 'Рассылка всем',
|
||||||
|
broadcast_marathon: 'Рассылка марафону',
|
||||||
|
admin_login: 'Вход админа',
|
||||||
|
admin_2fa_success: '2FA успех',
|
||||||
|
admin_2fa_fail: '2FA неудача',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_COLORS: Record<string, string> = {
|
||||||
|
user_ban: 'bg-red-500/20 text-red-400 border border-red-500/30',
|
||||||
|
user_unban: 'bg-green-500/20 text-green-400 border border-green-500/30',
|
||||||
|
user_auto_unban: 'bg-cyan-500/20 text-cyan-400 border border-cyan-500/30',
|
||||||
|
user_role_change: 'bg-accent-500/20 text-accent-400 border border-accent-500/30',
|
||||||
|
marathon_force_finish: 'bg-orange-500/20 text-orange-400 border border-orange-500/30',
|
||||||
|
marathon_delete: 'bg-red-500/20 text-red-400 border border-red-500/30',
|
||||||
|
content_update: 'bg-neon-500/20 text-neon-400 border border-neon-500/30',
|
||||||
|
broadcast_all: 'bg-pink-500/20 text-pink-400 border border-pink-500/30',
|
||||||
|
broadcast_marathon: 'bg-pink-500/20 text-pink-400 border border-pink-500/30',
|
||||||
|
admin_login: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
|
||||||
|
admin_2fa_success: 'bg-green-500/20 text-green-400 border border-green-500/30',
|
||||||
|
admin_2fa_fail: 'bg-red-500/20 text-red-400 border border-red-500/30',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminLogsPage() {
|
||||||
|
const [logs, setLogs] = useState<AdminLog[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [actionFilter, setActionFilter] = useState<string>('')
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const LIMIT = 30
|
||||||
|
|
||||||
|
const loadLogs = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.getLogs(page * LIMIT, LIMIT, actionFilter || undefined)
|
||||||
|
setLogs(data.logs)
|
||||||
|
setTotal(data.total)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load logs:', err)
|
||||||
|
toast.error('Ошибка загрузки логов')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [page, actionFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadLogs()
|
||||||
|
}, [loadLogs])
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / LIMIT)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-orange-500/20 border border-orange-500/30">
|
||||||
|
<ScrollText className="w-6 h-6 text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Логи действий</h1>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-400 bg-dark-700/50 px-3 py-1.5 rounded-lg border border-dark-600">
|
||||||
|
Всего: <span className="text-white font-medium">{total}</span> записей
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Filter className="w-5 h-5 text-gray-500" />
|
||||||
|
<select
|
||||||
|
value={actionFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setActionFilter(e.target.value)
|
||||||
|
setPage(0)
|
||||||
|
}}
|
||||||
|
className="bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-2.5 text-white focus:outline-none focus:border-accent-500/50 transition-colors min-w-[200px]"
|
||||||
|
>
|
||||||
|
<option value="">Все действия</option>
|
||||||
|
{Object.entries(ACTION_LABELS).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logs Table */}
|
||||||
|
<div className="glass rounded-xl border border-dark-600 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-dark-700/50 border-b border-dark-600">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Дата</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Админ</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Действие</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Цель</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Детали</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">IP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-dark-600">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 py-8 text-center">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
|
||||||
|
Логи не найдены
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
logs.map((log) => (
|
||||||
|
<tr key={log.id} className="hover:bg-dark-700/30 transition-colors">
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-400 whitespace-nowrap font-mono">
|
||||||
|
{formatDate(log.created_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm font-medium">
|
||||||
|
{log.admin_nickname ? (
|
||||||
|
<span className="text-white">{log.admin_nickname}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-cyan-400 italic">Система</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-flex px-2.5 py-1 text-xs font-medium rounded-lg ${ACTION_COLORS[log.action] || 'bg-dark-600/50 text-gray-400 border border-dark-500'}`}>
|
||||||
|
{ACTION_LABELS[log.action] || log.action}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-400">
|
||||||
|
<span className="text-gray-500">{log.target_type}</span>
|
||||||
|
<span className="text-neon-400 font-mono ml-1">#{log.target_id}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-500 max-w-xs">
|
||||||
|
{log.details ? (
|
||||||
|
<span className="font-mono text-xs bg-dark-700/50 px-2 py-1 rounded truncate block">
|
||||||
|
{JSON.stringify(log.details)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-600">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-500 font-mono">
|
||||||
|
{log.ip_address || '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-dark-600">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(Math.max(0, page - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
Страница <span className="text-white font-medium">{page + 1}</span> из <span className="text-white font-medium">{totalPages || 1}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
|
||||||
|
>
|
||||||
|
Вперед
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user