Compare commits
4 Commits
7a3576aec0
...
81d992abe6
| Author | SHA1 | Date | |
|---|---|---|---|
| 81d992abe6 | |||
| 9014d5d79d | |||
| 18ffff5473 | |||
| 475e2cf4cd |
6
Makefile
6
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help dev up down build build-no-cache logs restart clean migrate shell db-shell frontend-shell backend-shell lint test
|
.PHONY: help dev up down build build-no-cache logs logs-bot restart clean migrate shell db-shell frontend-shell backend-shell lint test
|
||||||
|
|
||||||
DC = sudo docker-compose
|
DC = sudo docker-compose
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ help:
|
|||||||
@echo " make logs - Show logs (all services)"
|
@echo " make logs - Show logs (all services)"
|
||||||
@echo " make logs-b - Show backend logs"
|
@echo " make logs-b - Show backend logs"
|
||||||
@echo " make logs-f - Show frontend logs"
|
@echo " make logs-f - Show frontend logs"
|
||||||
|
@echo " make logs-bot - Show Telegram bot logs"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo " Build:"
|
@echo " Build:"
|
||||||
@echo " make build - Build all containers (with cache)"
|
@echo " make build - Build all containers (with cache)"
|
||||||
@@ -63,6 +64,9 @@ logs-b:
|
|||||||
logs-f:
|
logs-f:
|
||||||
$(DC) logs -f frontend
|
$(DC) logs -f frontend
|
||||||
|
|
||||||
|
logs-bot:
|
||||||
|
$(DC) logs -f bot
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
build:
|
build:
|
||||||
$(DC) build
|
$(DC) build
|
||||||
|
|||||||
45
backend/alembic/versions/022_add_notification_settings.py
Normal file
45
backend/alembic/versions/022_add_notification_settings.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""Add notification settings to users
|
||||||
|
|
||||||
|
Revision ID: 022_add_notification_settings
|
||||||
|
Revises: 021_add_bonus_disputes
|
||||||
|
Create Date: 2025-01-04
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '022_add_notification_settings'
|
||||||
|
down_revision: Union[str, None] = '021_add_bonus_disputes'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add notification settings (all enabled by default)
|
||||||
|
if not column_exists('users', 'notify_events'):
|
||||||
|
op.add_column('users', sa.Column('notify_events', sa.Boolean(), server_default='true', nullable=False))
|
||||||
|
if not column_exists('users', 'notify_disputes'):
|
||||||
|
op.add_column('users', sa.Column('notify_disputes', sa.Boolean(), server_default='true', nullable=False))
|
||||||
|
if not column_exists('users', 'notify_moderation'):
|
||||||
|
op.add_column('users', sa.Column('notify_moderation', sa.Boolean(), server_default='true', nullable=False))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if column_exists('users', 'notify_moderation'):
|
||||||
|
op.drop_column('users', 'notify_moderation')
|
||||||
|
if column_exists('users', 'notify_disputes'):
|
||||||
|
op.drop_column('users', 'notify_disputes')
|
||||||
|
if column_exists('users', 'notify_events'):
|
||||||
|
op.drop_column('users', 'notify_events')
|
||||||
@@ -64,6 +64,28 @@ async def log_admin_action(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def build_admin_user_response(user: User, marathons_count: int) -> AdminUserResponse:
|
||||||
|
"""Build AdminUserResponse from User model."""
|
||||||
|
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,
|
||||||
|
notify_events=user.notify_events,
|
||||||
|
notify_disputes=user.notify_disputes,
|
||||||
|
notify_moderation=user.notify_moderation,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@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,
|
||||||
@@ -97,21 +119,7 @@ async def list_users(
|
|||||||
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)
|
||||||
)
|
)
|
||||||
response.append(AdminUserResponse(
|
response.append(build_admin_user_response(user, marathons_count))
|
||||||
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,
|
|
||||||
))
|
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -130,21 +138,7 @@ async def get_user(user_id: int, current_user: CurrentUser, db: DbSession):
|
|||||||
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
return AdminUserResponse(
|
return build_admin_user_response(user, marathons_count)
|
||||||
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.patch("/users/{user_id}/role", response_model=AdminUserResponse)
|
@router.patch("/users/{user_id}/role", response_model=AdminUserResponse)
|
||||||
@@ -184,21 +178,7 @@ async def set_user_role(
|
|||||||
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
return AdminUserResponse(
|
return build_admin_user_response(user, marathons_count)
|
||||||
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.delete("/users/{user_id}", response_model=MessageResponse)
|
@router.delete("/users/{user_id}", response_model=MessageResponse)
|
||||||
@@ -230,7 +210,7 @@ async def list_marathons(
|
|||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
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=200),
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
):
|
):
|
||||||
"""List all marathons. Admin only."""
|
"""List all marathons. Admin only."""
|
||||||
@@ -363,21 +343,7 @@ async def ban_user(
|
|||||||
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
return AdminUserResponse(
|
return build_admin_user_response(user, marathons_count)
|
||||||
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)
|
@router.post("/users/{user_id}/unban", response_model=AdminUserResponse)
|
||||||
@@ -418,21 +384,7 @@ async def unban_user(
|
|||||||
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
return AdminUserResponse(
|
return build_admin_user_response(user, marathons_count)
|
||||||
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 ============
|
# ============ Reset Password ============
|
||||||
@@ -478,21 +430,7 @@ async def reset_user_password(
|
|||||||
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
return AdminUserResponse(
|
return build_admin_user_response(user, marathons_count)
|
||||||
id=user.id,
|
|
||||||
login=user.login,
|
|
||||||
nickname=user.nickname,
|
|
||||||
role=user.role,
|
|
||||||
avatar_url=user.avatar_url,
|
|
||||||
telegram_id=user.telegram_id,
|
|
||||||
telegram_username=user.telegram_username,
|
|
||||||
marathons_count=marathons_count,
|
|
||||||
created_at=user.created_at.isoformat(),
|
|
||||||
is_banned=user.is_banned,
|
|
||||||
banned_at=user.banned_at.isoformat() if user.banned_at else None,
|
|
||||||
banned_until=user.banned_until.isoformat() if user.banned_until else None,
|
|
||||||
ban_reason=user.ban_reason,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ============ Force Finish Marathon ============
|
# ============ Force Finish Marathon ============
|
||||||
|
|||||||
@@ -99,7 +99,10 @@ async def list_challenges(game_id: int, current_user: CurrentUser, db: DbSession
|
|||||||
|
|
||||||
@router.get("/marathons/{marathon_id}/challenges", response_model=list[ChallengeResponse])
|
@router.get("/marathons/{marathon_id}/challenges", response_model=list[ChallengeResponse])
|
||||||
async def list_marathon_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
async def list_marathon_challenges(marathon_id: int, current_user: CurrentUser, db: DbSession):
|
||||||
"""List all challenges for a marathon (from all approved games). Participants only."""
|
"""List all challenges for a marathon (from all approved games). Participants only.
|
||||||
|
Also includes virtual challenges for playthrough-type games."""
|
||||||
|
from app.models.game import GameType
|
||||||
|
|
||||||
# Check marathon exists
|
# Check marathon exists
|
||||||
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()
|
||||||
@@ -111,7 +114,7 @@ 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 approved challenges from approved games in this marathon
|
# Get all approved challenges from approved games (challenges type) 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)
|
||||||
@@ -125,7 +128,47 @@ async def list_marathon_challenges(marathon_id: int, current_user: CurrentUser,
|
|||||||
)
|
)
|
||||||
challenges = result.scalars().all()
|
challenges = result.scalars().all()
|
||||||
|
|
||||||
return [build_challenge_response(c, c.game) for c in challenges]
|
responses = [build_challenge_response(c, c.game) for c in challenges]
|
||||||
|
|
||||||
|
# Also get playthrough-type games and create virtual challenges for them
|
||||||
|
result = await db.execute(
|
||||||
|
select(Game)
|
||||||
|
.where(
|
||||||
|
Game.marathon_id == marathon_id,
|
||||||
|
Game.status == GameStatus.APPROVED.value,
|
||||||
|
Game.game_type == GameType.PLAYTHROUGH.value,
|
||||||
|
)
|
||||||
|
.order_by(Game.title)
|
||||||
|
)
|
||||||
|
playthrough_games = result.scalars().all()
|
||||||
|
|
||||||
|
for game in playthrough_games:
|
||||||
|
# Create virtual challenge response for playthrough game
|
||||||
|
virtual_challenge = ChallengeResponse(
|
||||||
|
id=-game.id, # Negative ID to distinguish from real challenges
|
||||||
|
title=f"Прохождение: {game.title}",
|
||||||
|
description=game.playthrough_description or "Пройдите игру",
|
||||||
|
type="completion",
|
||||||
|
difficulty="medium",
|
||||||
|
points=game.playthrough_points or 0,
|
||||||
|
estimated_time=None,
|
||||||
|
proof_type=game.playthrough_proof_type or "screenshot",
|
||||||
|
proof_hint=game.playthrough_proof_hint,
|
||||||
|
game=GameShort(
|
||||||
|
id=game.id,
|
||||||
|
title=game.title,
|
||||||
|
cover_url=None,
|
||||||
|
download_url=game.download_url,
|
||||||
|
game_type=game.game_type
|
||||||
|
),
|
||||||
|
is_generated=False,
|
||||||
|
created_at=game.created_at,
|
||||||
|
status="approved",
|
||||||
|
proposed_by=None,
|
||||||
|
)
|
||||||
|
responses.append(virtual_challenge)
|
||||||
|
|
||||||
|
return responses
|
||||||
|
|
||||||
|
|
||||||
@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse)
|
@router.post("/games/{game_id}/challenges", response_model=ChallengeResponse)
|
||||||
|
|||||||
@@ -73,6 +73,21 @@ class TelegramStatsResponse(BaseModel):
|
|||||||
best_streak: int
|
best_streak: int
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramNotificationSettings(BaseModel):
|
||||||
|
notify_events: bool = True
|
||||||
|
notify_disputes: bool = True
|
||||||
|
notify_moderation: bool = True
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramNotificationSettingsUpdate(BaseModel):
|
||||||
|
notify_events: bool | None = None
|
||||||
|
notify_disputes: bool | None = None
|
||||||
|
notify_moderation: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
# Endpoints
|
# Endpoints
|
||||||
@router.post("/generate-link-token", response_model=TelegramLinkToken)
|
@router.post("/generate-link-token", response_model=TelegramLinkToken)
|
||||||
async def generate_link_token(current_user: CurrentUser):
|
async def generate_link_token(current_user: CurrentUser):
|
||||||
@@ -391,3 +406,46 @@ async def get_user_stats(telegram_id: int, db: DbSession, _: BotSecretDep):
|
|||||||
total_points=total_points,
|
total_points=total_points,
|
||||||
best_streak=best_streak
|
best_streak=best_streak
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/notifications/{telegram_id}", response_model=TelegramNotificationSettings | None)
|
||||||
|
async def get_notification_settings(telegram_id: int, db: DbSession, _: BotSecretDep):
|
||||||
|
"""Get user's notification settings by Telegram ID."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return TelegramNotificationSettings.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/notifications/{telegram_id}", response_model=TelegramNotificationSettings | None)
|
||||||
|
async def update_notification_settings(
|
||||||
|
telegram_id: int,
|
||||||
|
data: TelegramNotificationSettingsUpdate,
|
||||||
|
db: DbSession,
|
||||||
|
_: BotSecretDep
|
||||||
|
):
|
||||||
|
"""Update user's notification settings by Telegram ID."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(User).where(User.telegram_id == telegram_id)
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if data.notify_events is not None:
|
||||||
|
user.notify_events = data.notify_events
|
||||||
|
if data.notify_disputes is not None:
|
||||||
|
user.notify_disputes = data.notify_disputes
|
||||||
|
if data.notify_moderation is not None:
|
||||||
|
user.notify_moderation = data.notify_moderation
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
|
||||||
|
return TelegramNotificationSettings.model_validate(user)
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ 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, UserPrivate, UserUpdate, TelegramLink, MessageResponse,
|
UserPublic, UserPrivate, UserUpdate, TelegramLink, MessageResponse,
|
||||||
PasswordChange, UserStats, UserProfilePublic,
|
PasswordChange, UserStats, UserProfilePublic, NotificationSettings,
|
||||||
|
NotificationSettingsUpdate,
|
||||||
)
|
)
|
||||||
from app.services.storage import storage_service
|
from app.services.storage import storage_service
|
||||||
|
|
||||||
@@ -189,6 +190,32 @@ async def change_password(
|
|||||||
return MessageResponse(message="Пароль успешно изменен")
|
return MessageResponse(message="Пароль успешно изменен")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/notifications", response_model=NotificationSettings)
|
||||||
|
async def get_notification_settings(current_user: CurrentUser):
|
||||||
|
"""Get current user's notification settings"""
|
||||||
|
return NotificationSettings.model_validate(current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me/notifications", response_model=NotificationSettings)
|
||||||
|
async def update_notification_settings(
|
||||||
|
data: NotificationSettingsUpdate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbSession,
|
||||||
|
):
|
||||||
|
"""Update current user's notification settings"""
|
||||||
|
if data.notify_events is not None:
|
||||||
|
current_user.notify_events = data.notify_events
|
||||||
|
if data.notify_disputes is not None:
|
||||||
|
current_user.notify_disputes = data.notify_disputes
|
||||||
|
if data.notify_moderation is not None:
|
||||||
|
current_user.notify_moderation = data.notify_moderation
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(current_user)
|
||||||
|
|
||||||
|
return NotificationSettings.model_validate(current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me/stats", response_model=UserStats)
|
@router.get("/me/stats", response_model=UserStats)
|
||||||
async def get_my_stats(current_user: CurrentUser, db: DbSession):
|
async def get_my_stats(current_user: CurrentUser, db: DbSession):
|
||||||
"""Получить свою статистику"""
|
"""Получить свою статистику"""
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ class User(Base):
|
|||||||
banned_by_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("users.id"), nullable=True)
|
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)
|
ban_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
|
# Notification settings (all enabled by default)
|
||||||
|
notify_events: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
notify_disputes: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
notify_moderation: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
created_marathons: Mapped[list["Marathon"]] = relationship(
|
created_marathons: Mapped[list["Marathon"]] = relationship(
|
||||||
"Marathon",
|
"Marathon",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from app.schemas.user import (
|
|||||||
PasswordChange,
|
PasswordChange,
|
||||||
UserStats,
|
UserStats,
|
||||||
UserProfilePublic,
|
UserProfilePublic,
|
||||||
|
NotificationSettings,
|
||||||
|
NotificationSettingsUpdate,
|
||||||
)
|
)
|
||||||
from app.schemas.marathon import (
|
from app.schemas.marathon import (
|
||||||
MarathonCreate,
|
MarathonCreate,
|
||||||
@@ -115,6 +117,8 @@ __all__ = [
|
|||||||
"PasswordChange",
|
"PasswordChange",
|
||||||
"UserStats",
|
"UserStats",
|
||||||
"UserProfilePublic",
|
"UserProfilePublic",
|
||||||
|
"NotificationSettings",
|
||||||
|
"NotificationSettingsUpdate",
|
||||||
# Marathon
|
# Marathon
|
||||||
"MarathonCreate",
|
"MarathonCreate",
|
||||||
"MarathonUpdate",
|
"MarathonUpdate",
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ class AdminUserResponse(BaseModel):
|
|||||||
banned_at: str | None = None
|
banned_at: str | None = None
|
||||||
banned_until: str | None = None # None = permanent
|
banned_until: str | None = None # None = permanent
|
||||||
ban_reason: str | None = None
|
ban_reason: str | None = None
|
||||||
|
# Notification settings
|
||||||
|
notify_events: bool = True
|
||||||
|
notify_disputes: bool = True
|
||||||
|
notify_moderation: bool = True
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ class UserPrivate(UserPublic):
|
|||||||
telegram_username: str | None = None
|
telegram_username: str | None = None
|
||||||
telegram_first_name: str | None = None
|
telegram_first_name: str | None = None
|
||||||
telegram_last_name: str | None = None
|
telegram_last_name: str | None = None
|
||||||
|
# Notification settings
|
||||||
|
notify_events: bool = True
|
||||||
|
notify_disputes: bool = True
|
||||||
|
notify_moderation: bool = True
|
||||||
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
@@ -83,3 +87,20 @@ class UserProfilePublic(BaseModel):
|
|||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationSettings(BaseModel):
|
||||||
|
"""Notification settings for Telegram bot"""
|
||||||
|
notify_events: bool = True
|
||||||
|
notify_disputes: bool = True
|
||||||
|
notify_moderation: bool = True
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationSettingsUpdate(BaseModel):
|
||||||
|
"""Update notification settings"""
|
||||||
|
notify_events: bool | None = None
|
||||||
|
notify_disputes: bool | None = None
|
||||||
|
notify_moderation: bool | None = None
|
||||||
|
|||||||
@@ -83,9 +83,15 @@ class TelegramNotifier:
|
|||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
marathon_id: int,
|
marathon_id: int,
|
||||||
message: str,
|
message: str,
|
||||||
exclude_user_id: int | None = None
|
exclude_user_id: int | None = None,
|
||||||
|
check_setting: str | None = None
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Send notification to all marathon participants with linked Telegram."""
|
"""Send notification to all marathon participants with linked Telegram.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
check_setting: If provided, only send to users with this setting enabled.
|
||||||
|
Options: 'notify_events', 'notify_disputes', 'notify_moderation'
|
||||||
|
"""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(User)
|
select(User)
|
||||||
.join(Participant, Participant.user_id == User.id)
|
.join(Participant, Participant.user_id == User.id)
|
||||||
@@ -100,6 +106,10 @@ class TelegramNotifier:
|
|||||||
for user in users:
|
for user in users:
|
||||||
if exclude_user_id and user.id == exclude_user_id:
|
if exclude_user_id and user.id == exclude_user_id:
|
||||||
continue
|
continue
|
||||||
|
# Check notification setting if specified
|
||||||
|
if check_setting and not getattr(user, check_setting, True):
|
||||||
|
logger.info(f"[Notify] Skipping user {user.nickname} - {check_setting} is disabled")
|
||||||
|
continue
|
||||||
if await self.send_message(user.telegram_id, message):
|
if await self.send_message(user.telegram_id, message):
|
||||||
sent_count += 1
|
sent_count += 1
|
||||||
|
|
||||||
@@ -113,7 +123,7 @@ class TelegramNotifier:
|
|||||||
event_type: str,
|
event_type: str,
|
||||||
marathon_title: str
|
marathon_title: str
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Notify participants about event start."""
|
"""Notify participants about event start (respects notify_events setting)."""
|
||||||
event_messages = {
|
event_messages = {
|
||||||
"golden_hour": f"🌟 <b>Начался Golden Hour</b> в «{marathon_title}»!\n\nВсе очки x1.5 в течение часа!",
|
"golden_hour": f"🌟 <b>Начался Golden Hour</b> в «{marathon_title}»!\n\nВсе очки x1.5 в течение часа!",
|
||||||
"jackpot": f"🎰 <b>JACKPOT</b> в «{marathon_title}»!\n\nОчки x3 за следующий сложный челлендж!",
|
"jackpot": f"🎰 <b>JACKPOT</b> в «{marathon_title}»!\n\nОчки x3 за следующий сложный челлендж!",
|
||||||
@@ -128,7 +138,9 @@ class TelegramNotifier:
|
|||||||
f"📌 Новое событие в «{marathon_title}»!"
|
f"📌 Новое событие в «{marathon_title}»!"
|
||||||
)
|
)
|
||||||
|
|
||||||
return await self.notify_marathon_participants(db, marathon_id, message)
|
return await self.notify_marathon_participants(
|
||||||
|
db, marathon_id, message, check_setting='notify_events'
|
||||||
|
)
|
||||||
|
|
||||||
async def notify_event_end(
|
async def notify_event_end(
|
||||||
self,
|
self,
|
||||||
@@ -137,7 +149,7 @@ class TelegramNotifier:
|
|||||||
event_type: str,
|
event_type: str,
|
||||||
marathon_title: str
|
marathon_title: str
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Notify participants about event end."""
|
"""Notify participants about event end (respects notify_events setting)."""
|
||||||
event_names = {
|
event_names = {
|
||||||
"golden_hour": "Golden Hour",
|
"golden_hour": "Golden Hour",
|
||||||
"jackpot": "Jackpot",
|
"jackpot": "Jackpot",
|
||||||
@@ -150,7 +162,9 @@ class TelegramNotifier:
|
|||||||
event_name = event_names.get(event_type, "Событие")
|
event_name = event_names.get(event_type, "Событие")
|
||||||
message = f"⏰ <b>{event_name}</b> в «{marathon_title}» завершён"
|
message = f"⏰ <b>{event_name}</b> в «{marathon_title}» завершён"
|
||||||
|
|
||||||
return await self.notify_marathon_participants(db, marathon_id, message)
|
return await self.notify_marathon_participants(
|
||||||
|
db, marathon_id, message, check_setting='notify_events'
|
||||||
|
)
|
||||||
|
|
||||||
async def notify_marathon_start(
|
async def notify_marathon_start(
|
||||||
self,
|
self,
|
||||||
@@ -186,7 +200,14 @@ class TelegramNotifier:
|
|||||||
challenge_title: str,
|
challenge_title: str,
|
||||||
assignment_id: int
|
assignment_id: int
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Notify user about dispute raised on their assignment."""
|
"""Notify user about dispute raised on their assignment (respects notify_disputes setting)."""
|
||||||
|
# Check user's notification settings
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user and not user.notify_disputes:
|
||||||
|
logger.info(f"[Dispute] Skipping user {user.nickname} - notify_disputes is disabled")
|
||||||
|
return False
|
||||||
|
|
||||||
logger.info(f"[Dispute] Sending notification to user_id={user_id} for assignment_id={assignment_id}")
|
logger.info(f"[Dispute] Sending notification to user_id={user_id} for assignment_id={assignment_id}")
|
||||||
|
|
||||||
dispute_url = f"{settings.FRONTEND_URL}/assignments/{assignment_id}"
|
dispute_url = f"{settings.FRONTEND_URL}/assignments/{assignment_id}"
|
||||||
@@ -227,7 +248,14 @@ class TelegramNotifier:
|
|||||||
challenge_title: str,
|
challenge_title: str,
|
||||||
is_valid: bool
|
is_valid: bool
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Notify user about dispute resolution."""
|
"""Notify user about dispute resolution (respects notify_disputes setting)."""
|
||||||
|
# Check user's notification settings
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user and not user.notify_disputes:
|
||||||
|
logger.info(f"[Dispute] Skipping user {user.nickname} - notify_disputes is disabled")
|
||||||
|
return False
|
||||||
|
|
||||||
if is_valid:
|
if is_valid:
|
||||||
message = (
|
message = (
|
||||||
f"❌ <b>Спор признан обоснованным</b>\n\n"
|
f"❌ <b>Спор признан обоснованным</b>\n\n"
|
||||||
@@ -251,7 +279,14 @@ class TelegramNotifier:
|
|||||||
marathon_title: str,
|
marathon_title: str,
|
||||||
game_title: str
|
game_title: str
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Notify user that their proposed game was approved."""
|
"""Notify user that their proposed game was approved (respects notify_moderation setting)."""
|
||||||
|
# Check user's notification settings
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user and not user.notify_moderation:
|
||||||
|
logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled")
|
||||||
|
return False
|
||||||
|
|
||||||
message = (
|
message = (
|
||||||
f"✅ <b>Твоя игра одобрена!</b>\n\n"
|
f"✅ <b>Твоя игра одобрена!</b>\n\n"
|
||||||
f"Марафон: {marathon_title}\n"
|
f"Марафон: {marathon_title}\n"
|
||||||
@@ -267,7 +302,14 @@ class TelegramNotifier:
|
|||||||
marathon_title: str,
|
marathon_title: str,
|
||||||
game_title: str
|
game_title: str
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Notify user that their proposed game was rejected."""
|
"""Notify user that their proposed game was rejected (respects notify_moderation setting)."""
|
||||||
|
# Check user's notification settings
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user and not user.notify_moderation:
|
||||||
|
logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled")
|
||||||
|
return False
|
||||||
|
|
||||||
message = (
|
message = (
|
||||||
f"❌ <b>Твоя игра отклонена</b>\n\n"
|
f"❌ <b>Твоя игра отклонена</b>\n\n"
|
||||||
f"Марафон: {marathon_title}\n"
|
f"Марафон: {marathon_title}\n"
|
||||||
@@ -284,7 +326,14 @@ class TelegramNotifier:
|
|||||||
game_title: str,
|
game_title: str,
|
||||||
challenge_title: str
|
challenge_title: str
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Notify user that their proposed challenge was approved."""
|
"""Notify user that their proposed challenge was approved (respects notify_moderation setting)."""
|
||||||
|
# Check user's notification settings
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user and not user.notify_moderation:
|
||||||
|
logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled")
|
||||||
|
return False
|
||||||
|
|
||||||
message = (
|
message = (
|
||||||
f"✅ <b>Твой челлендж одобрен!</b>\n\n"
|
f"✅ <b>Твой челлендж одобрен!</b>\n\n"
|
||||||
f"Марафон: {marathon_title}\n"
|
f"Марафон: {marathon_title}\n"
|
||||||
@@ -302,7 +351,14 @@ class TelegramNotifier:
|
|||||||
game_title: str,
|
game_title: str,
|
||||||
challenge_title: str
|
challenge_title: str
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Notify user that their proposed challenge was rejected."""
|
"""Notify user that their proposed challenge was rejected (respects notify_moderation setting)."""
|
||||||
|
# Check user's notification settings
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user and not user.notify_moderation:
|
||||||
|
logger.info(f"[Moderation] Skipping user {user.nickname} - notify_moderation is disabled")
|
||||||
|
return False
|
||||||
|
|
||||||
message = (
|
message = (
|
||||||
f"❌ <b>Твой челлендж отклонён</b>\n\n"
|
f"❌ <b>Твой челлендж отклонён</b>\n\n"
|
||||||
f"Марафон: {marathon_title}\n"
|
f"Марафон: {marathon_title}\n"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from aiogram.filters import Command
|
|||||||
from aiogram.types import Message, CallbackQuery
|
from aiogram.types import Message, CallbackQuery
|
||||||
|
|
||||||
from keyboards.main_menu import get_main_menu
|
from keyboards.main_menu import get_main_menu
|
||||||
from keyboards.inline import get_marathons_keyboard, get_marathon_details_keyboard
|
from keyboards.inline import get_marathons_keyboard, get_marathon_details_keyboard, get_settings_keyboard
|
||||||
from services.api_client import api_client
|
from services.api_client import api_client
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
@@ -197,15 +197,66 @@ async def cmd_settings(message: Message):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Get current notification settings
|
||||||
|
settings = await api_client.get_notification_settings(message.from_user.id)
|
||||||
|
if not settings:
|
||||||
|
settings = {"notify_events": True, "notify_disputes": True, "notify_moderation": True}
|
||||||
|
|
||||||
await message.answer(
|
await message.answer(
|
||||||
"<b>⚙️ Настройки</b>\n\n"
|
"<b>⚙️ Настройки уведомлений</b>\n\n"
|
||||||
"Управление уведомлениями будет доступно в следующем обновлении.\n\n"
|
"Нажми на категорию, чтобы включить/выключить:\n\n"
|
||||||
"Сейчас ты получаешь все уведомления:\n"
|
"✅ — уведомления включены\n"
|
||||||
"• 🌟 События (Golden Hour, Jackpot и др.)\n"
|
"❌ — уведомления выключены\n\n"
|
||||||
"• 🚀 Старт/финиш марафонов\n"
|
"<i>Уведомления о старте/финише марафонов и коды безопасности нельзя отключить.</i>",
|
||||||
"• ⚠️ Споры по заданиям\n\n"
|
reply_markup=get_settings_keyboard(settings)
|
||||||
"Команды:\n"
|
)
|
||||||
"/unlink - Отвязать аккаунт\n"
|
|
||||||
"/status - Проверить привязку",
|
|
||||||
|
@router.callback_query(F.data.startswith("toggle:"))
|
||||||
|
async def toggle_notification(callback: CallbackQuery):
|
||||||
|
"""Toggle notification setting."""
|
||||||
|
setting_name = callback.data.split(":")[1]
|
||||||
|
|
||||||
|
# Get current settings
|
||||||
|
current_settings = await api_client.get_notification_settings(callback.from_user.id)
|
||||||
|
if not current_settings:
|
||||||
|
await callback.answer("Не удалось загрузить настройки", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Toggle the setting
|
||||||
|
current_value = current_settings.get(setting_name, True)
|
||||||
|
new_value = not current_value
|
||||||
|
|
||||||
|
# Update on backend
|
||||||
|
result = await api_client.update_notification_settings(
|
||||||
|
callback.from_user.id,
|
||||||
|
{setting_name: new_value}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result or result.get("error"):
|
||||||
|
await callback.answer("Не удалось сохранить настройки", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update keyboard with new values
|
||||||
|
await callback.message.edit_reply_markup(
|
||||||
|
reply_markup=get_settings_keyboard(result)
|
||||||
|
)
|
||||||
|
|
||||||
|
status = "включены" if new_value else "выключены"
|
||||||
|
setting_names = {
|
||||||
|
"notify_events": "События",
|
||||||
|
"notify_disputes": "Споры",
|
||||||
|
"notify_moderation": "Модерация"
|
||||||
|
}
|
||||||
|
await callback.answer(f"{setting_names.get(setting_name, setting_name)}: {status}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "back_to_menu")
|
||||||
|
async def back_to_menu(callback: CallbackQuery):
|
||||||
|
"""Go back to main menu from settings."""
|
||||||
|
await callback.message.delete()
|
||||||
|
await callback.message.answer(
|
||||||
|
"Главное меню",
|
||||||
reply_markup=get_main_menu()
|
reply_markup=get_main_menu()
|
||||||
)
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|||||||
@@ -40,3 +40,45 @@ def get_marathon_details_keyboard(marathon_id: int) -> InlineKeyboardMarkup:
|
|||||||
]
|
]
|
||||||
|
|
||||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings_keyboard(settings: dict) -> InlineKeyboardMarkup:
|
||||||
|
"""Create keyboard for notification settings."""
|
||||||
|
# Get current values with defaults
|
||||||
|
notify_events = settings.get("notify_events", True)
|
||||||
|
notify_disputes = settings.get("notify_disputes", True)
|
||||||
|
notify_moderation = settings.get("notify_moderation", True)
|
||||||
|
|
||||||
|
# Status indicators
|
||||||
|
events_status = "✅" if notify_events else "❌"
|
||||||
|
disputes_status = "✅" if notify_disputes else "❌"
|
||||||
|
moderation_status = "✅" if notify_moderation else "❌"
|
||||||
|
|
||||||
|
buttons = [
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{events_status} События (Golden Hour, Jackpot...)",
|
||||||
|
callback_data="toggle:notify_events"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{disputes_status} Споры",
|
||||||
|
callback_data="toggle:notify_disputes"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{moderation_status} Модерация (игры/челленджи)",
|
||||||
|
callback_data="toggle:notify_moderation"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="◀️ Назад",
|
||||||
|
callback_data="back_to_menu"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|||||||
@@ -124,6 +124,22 @@ class APIClient:
|
|||||||
"""Get user's overall statistics."""
|
"""Get user's overall statistics."""
|
||||||
return await self._request("GET", f"/telegram/stats/{telegram_id}")
|
return await self._request("GET", f"/telegram/stats/{telegram_id}")
|
||||||
|
|
||||||
|
async def get_notification_settings(self, telegram_id: int) -> dict[str, Any] | None:
|
||||||
|
"""Get user's notification settings."""
|
||||||
|
return await self._request("GET", f"/telegram/notifications/{telegram_id}")
|
||||||
|
|
||||||
|
async def update_notification_settings(
|
||||||
|
self,
|
||||||
|
telegram_id: int,
|
||||||
|
settings: dict[str, bool]
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Update user's notification settings."""
|
||||||
|
return await self._request(
|
||||||
|
"PATCH",
|
||||||
|
f"/telegram/notifications/{telegram_id}",
|
||||||
|
json=settings
|
||||||
|
)
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
"""Close the HTTP session."""
|
"""Close the HTTP session."""
|
||||||
if self._session and not self._session.closed:
|
if self._session and not self._session.closed:
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
AdminDashboardPage,
|
AdminDashboardPage,
|
||||||
AdminUsersPage,
|
AdminUsersPage,
|
||||||
AdminMarathonsPage,
|
AdminMarathonsPage,
|
||||||
AdminDisputesPage,
|
|
||||||
AdminLogsPage,
|
AdminLogsPage,
|
||||||
AdminBroadcastPage,
|
AdminBroadcastPage,
|
||||||
AdminContentPage,
|
AdminContentPage,
|
||||||
@@ -209,7 +208,6 @@ function App() {
|
|||||||
<Route index element={<AdminDashboardPage />} />
|
<Route index element={<AdminDashboardPage />} />
|
||||||
<Route path="users" element={<AdminUsersPage />} />
|
<Route path="users" element={<AdminUsersPage />} />
|
||||||
<Route path="marathons" element={<AdminMarathonsPage />} />
|
<Route path="marathons" element={<AdminMarathonsPage />} />
|
||||||
<Route path="disputes" element={<AdminDisputesPage />} />
|
|
||||||
<Route path="logs" element={<AdminLogsPage />} />
|
<Route path="logs" element={<AdminLogsPage />} />
|
||||||
<Route path="broadcast" element={<AdminBroadcastPage />} />
|
<Route path="broadcast" element={<AdminBroadcastPage />} />
|
||||||
<Route path="content" element={<AdminContentPage />} />
|
<Route path="content" element={<AdminContentPage />} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import client from './client'
|
import client from './client'
|
||||||
import type { User, UserProfilePublic, UserStats, PasswordChangeData } from '@/types'
|
import type { User, UserProfilePublic, UserStats, PasswordChangeData, NotificationSettings, NotificationSettingsUpdate } from '@/types'
|
||||||
|
|
||||||
export interface UpdateNicknameData {
|
export interface UpdateNicknameData {
|
||||||
nickname: string
|
nickname: string
|
||||||
@@ -48,4 +48,16 @@ export const usersApi = {
|
|||||||
})
|
})
|
||||||
return URL.createObjectURL(response.data)
|
return URL.createObjectURL(response.data)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Получить настройки уведомлений
|
||||||
|
getNotificationSettings: async (): Promise<NotificationSettings> => {
|
||||||
|
const response = await client.get<NotificationSettings>('/users/me/notifications')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Обновить настройки уведомлений
|
||||||
|
updateNotificationSettings: async (data: NotificationSettingsUpdate): Promise<NotificationSettings> => {
|
||||||
|
const response = await client.patch<NotificationSettings>('/users/me/notifications', data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
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, Shield
|
Eye, EyeOff, Save, KeyRound, Shield, Bell, Sparkles,
|
||||||
|
AlertTriangle, FileCheck
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// Schemas
|
// Schemas
|
||||||
@@ -51,6 +52,12 @@ export function ProfilePage() {
|
|||||||
const [isPolling, setIsPolling] = useState(false)
|
const [isPolling, setIsPolling] = useState(false)
|
||||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
// Notification settings state
|
||||||
|
const [notifyEvents, setNotifyEvents] = useState(user?.notify_events ?? true)
|
||||||
|
const [notifyDisputes, setNotifyDisputes] = useState(user?.notify_disputes ?? true)
|
||||||
|
const [notifyModeration, setNotifyModeration] = useState(user?.notify_moderation ?? true)
|
||||||
|
const [notificationUpdating, setNotificationUpdating] = useState<string | null>(null)
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// Forms
|
// Forms
|
||||||
@@ -265,6 +272,29 @@ export function ProfilePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update notification setting
|
||||||
|
const handleNotificationToggle = async (
|
||||||
|
setting: 'notify_events' | 'notify_disputes' | 'notify_moderation',
|
||||||
|
currentValue: boolean,
|
||||||
|
setValue: (value: boolean) => void
|
||||||
|
) => {
|
||||||
|
setNotificationUpdating(setting)
|
||||||
|
const newValue = !currentValue
|
||||||
|
setValue(newValue)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await usersApi.updateNotificationSettings({ [setting]: newValue })
|
||||||
|
updateUser({ [setting]: newValue })
|
||||||
|
toast.success('Настройки сохранены')
|
||||||
|
} catch {
|
||||||
|
// Revert on error
|
||||||
|
setValue(currentValue)
|
||||||
|
toast.error('Не удалось сохранить настройки')
|
||||||
|
} finally {
|
||||||
|
setNotificationUpdating(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isLinked = !!user?.telegram_id
|
const isLinked = !!user?.telegram_id
|
||||||
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
|
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
|
||||||
|
|
||||||
@@ -544,6 +574,109 @@ export function ProfilePage() {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
{isLinked && (
|
||||||
|
<GlassCard>
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-neon-500/20 flex items-center justify-center">
|
||||||
|
<Bell className="w-6 h-6 text-neon-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Уведомления</h2>
|
||||||
|
<p className="text-sm text-gray-400">Настройте типы уведомлений в Telegram</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Events toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleNotificationToggle('notify_events', notifyEvents, setNotifyEvents)}
|
||||||
|
disabled={notificationUpdating !== null}
|
||||||
|
className="w-full flex items-center justify-between p-4 bg-dark-700/50 rounded-xl border border-dark-600 hover:border-dark-500 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
|
||||||
|
<Sparkles className="w-5 h-5 text-yellow-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="text-white font-medium">События</p>
|
||||||
|
<p className="text-sm text-gray-400">Golden Hour, Jackpot, Double Risk и др.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`
|
||||||
|
w-12 h-7 rounded-full transition-colors relative flex-shrink-0
|
||||||
|
${notifyEvents ? 'bg-neon-500' : 'bg-dark-600'}
|
||||||
|
${notificationUpdating === 'notify_events' ? 'opacity-50' : ''}
|
||||||
|
`}>
|
||||||
|
<div className={`
|
||||||
|
absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-transform
|
||||||
|
${notifyEvents ? 'left-6' : 'left-1'}
|
||||||
|
`} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Disputes toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleNotificationToggle('notify_disputes', notifyDisputes, setNotifyDisputes)}
|
||||||
|
disabled={notificationUpdating !== null}
|
||||||
|
className="w-full flex items-center justify-between p-4 bg-dark-700/50 rounded-xl border border-dark-600 hover:border-dark-500 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-orange-500/20 flex items-center justify-center">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="text-white font-medium">Споры</p>
|
||||||
|
<p className="text-sm text-gray-400">Оспаривания заданий и их решения</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`
|
||||||
|
w-12 h-7 rounded-full transition-colors relative flex-shrink-0
|
||||||
|
${notifyDisputes ? 'bg-neon-500' : 'bg-dark-600'}
|
||||||
|
${notificationUpdating === 'notify_disputes' ? 'opacity-50' : ''}
|
||||||
|
`}>
|
||||||
|
<div className={`
|
||||||
|
absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-transform
|
||||||
|
${notifyDisputes ? 'left-6' : 'left-1'}
|
||||||
|
`} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Moderation toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleNotificationToggle('notify_moderation', notifyModeration, setNotifyModeration)}
|
||||||
|
disabled={notificationUpdating !== null}
|
||||||
|
className="w-full flex items-center justify-between p-4 bg-dark-700/50 rounded-xl border border-dark-600 hover:border-dark-500 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center">
|
||||||
|
<FileCheck className="w-5 h-5 text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="text-white font-medium">Модерация</p>
|
||||||
|
<p className="text-sm text-gray-400">Одобрение/отклонение игр и челленджей</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`
|
||||||
|
w-12 h-7 rounded-full transition-colors relative flex-shrink-0
|
||||||
|
${notifyModeration ? 'bg-neon-500' : 'bg-dark-600'}
|
||||||
|
${notificationUpdating === 'notify_moderation' ? 'opacity-50' : ''}
|
||||||
|
`}>
|
||||||
|
<div className={`
|
||||||
|
absolute top-1 w-5 h-5 rounded-full bg-white shadow transition-transform
|
||||||
|
${notifyModeration ? 'left-6' : 'left-1'}
|
||||||
|
`} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Info about mandatory notifications */}
|
||||||
|
<p className="text-xs text-gray-500 mt-4">
|
||||||
|
Уведомления о старте/финише марафонов и коды безопасности нельзя отключить.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,34 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
|
||||||
import { adminApi } from '@/api'
|
import { adminApi } from '@/api'
|
||||||
import type { AdminMarathon } from '@/types'
|
import type { AdminMarathon } from '@/types'
|
||||||
import { useToast } from '@/store/toast'
|
import { useToast } from '@/store/toast'
|
||||||
import { NeonButton } from '@/components/ui'
|
import { NeonButton } from '@/components/ui'
|
||||||
import { Send, Users, Trophy, AlertTriangle } from 'lucide-react'
|
import { Send, Users, Trophy, AlertTriangle, Search, Eye, MessageSquare, ChevronDown, X } from 'lucide-react'
|
||||||
|
|
||||||
|
// Telegram supported tags for reference
|
||||||
|
const TELEGRAM_TAGS = [
|
||||||
|
{ tag: '<b>', description: 'Жирный текст', example: '<b>жирный</b>' },
|
||||||
|
{ tag: '<i>', description: 'Курсив', example: '<i>курсив</i>' },
|
||||||
|
{ tag: '<u>', description: 'Подчёркнутый', example: '<u>подчёркнутый</u>' },
|
||||||
|
{ tag: '<s>', description: 'Зачёркнутый', example: '<s>зачёркнутый</s>' },
|
||||||
|
{ tag: '<code>', description: 'Моноширинный', example: '<code>код</code>' },
|
||||||
|
{ tag: '<pre>', description: 'Блок кода', example: '<pre>блок кода</pre>' },
|
||||||
|
{ tag: '<a>', description: 'Ссылка', example: '<a href="url">текст</a>' },
|
||||||
|
{ tag: '<tg-spoiler>', description: 'Спойлер', example: '<tg-spoiler>скрытый текст</tg-spoiler>' },
|
||||||
|
{ tag: '<blockquote>', description: 'Цитата', example: '<blockquote>цитата</blockquote>' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Convert Telegram HTML to web-safe HTML for preview
|
||||||
|
function telegramToHtml(text: string): string {
|
||||||
|
let html = text
|
||||||
|
// Convert tg-spoiler to span with blur effect
|
||||||
|
.replace(/<tg-spoiler>/g, '<span class="tg-spoiler">')
|
||||||
|
.replace(/<\/tg-spoiler>/g, '</span>')
|
||||||
|
// Convert newlines to <br> tags (but not inside <pre> blocks)
|
||||||
|
.replace(/\n/g, '<br>')
|
||||||
|
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminBroadcastPage() {
|
export function AdminBroadcastPage() {
|
||||||
const [message, setMessage] = useState('')
|
const [message, setMessage] = useState('')
|
||||||
@@ -12,11 +37,26 @@ export function AdminBroadcastPage() {
|
|||||||
const [marathons, setMarathons] = useState<AdminMarathon[]>([])
|
const [marathons, setMarathons] = useState<AdminMarathon[]>([])
|
||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
const [loadingMarathons, setLoadingMarathons] = useState(false)
|
const [loadingMarathons, setLoadingMarathons] = useState(false)
|
||||||
|
const [marathonSearch, setMarathonSearch] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'preparing' | 'finished'>('all')
|
||||||
|
const [showTagsHelp, setShowTagsHelp] = useState(false)
|
||||||
|
|
||||||
|
// Undo/Redo history
|
||||||
|
const [history, setHistory] = useState<string[]>([''])
|
||||||
|
const [historyIndex, setHistoryIndex] = useState(0)
|
||||||
|
const isUndoRedo = useRef(false)
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
|
// Load marathons on mount and when switching to marathon target
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (targetType === 'marathon') {
|
// Always load marathons on mount so they're ready when user switches
|
||||||
|
loadMarathons()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (targetType === 'marathon' && marathons.length === 0) {
|
||||||
loadMarathons()
|
loadMarathons()
|
||||||
}
|
}
|
||||||
}, [targetType])
|
}, [targetType])
|
||||||
@@ -24,15 +64,86 @@ export function AdminBroadcastPage() {
|
|||||||
const loadMarathons = async () => {
|
const loadMarathons = async () => {
|
||||||
setLoadingMarathons(true)
|
setLoadingMarathons(true)
|
||||||
try {
|
try {
|
||||||
const data = await adminApi.listMarathons(0, 100)
|
const data = await adminApi.listMarathons(0, 200)
|
||||||
setMarathons(data.filter(m => m.status === 'active'))
|
console.log('Loaded marathons:', data)
|
||||||
|
setMarathons(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load marathons:', err)
|
console.error('Failed to load marathons:', err)
|
||||||
|
toast.error('Ошибка загрузки марафонов')
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingMarathons(false)
|
setLoadingMarathons(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle message change with history
|
||||||
|
const handleMessageChange = useCallback((newValue: string) => {
|
||||||
|
if (isUndoRedo.current) {
|
||||||
|
isUndoRedo.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessage(newValue)
|
||||||
|
|
||||||
|
// Add to history (debounced - only if different from last entry)
|
||||||
|
setHistory(prev => {
|
||||||
|
const newHistory = prev.slice(0, historyIndex + 1)
|
||||||
|
if (newHistory[newHistory.length - 1] !== newValue) {
|
||||||
|
return [...newHistory, newValue]
|
||||||
|
}
|
||||||
|
return newHistory
|
||||||
|
})
|
||||||
|
setHistoryIndex(prev => prev + 1)
|
||||||
|
}, [historyIndex])
|
||||||
|
|
||||||
|
// Undo function
|
||||||
|
const undo = useCallback(() => {
|
||||||
|
if (historyIndex > 0) {
|
||||||
|
isUndoRedo.current = true
|
||||||
|
const newIndex = historyIndex - 1
|
||||||
|
setHistoryIndex(newIndex)
|
||||||
|
setMessage(history[newIndex])
|
||||||
|
}
|
||||||
|
}, [history, historyIndex])
|
||||||
|
|
||||||
|
// Redo function
|
||||||
|
const redo = useCallback(() => {
|
||||||
|
if (historyIndex < history.length - 1) {
|
||||||
|
isUndoRedo.current = true
|
||||||
|
const newIndex = historyIndex + 1
|
||||||
|
setHistoryIndex(newIndex)
|
||||||
|
setMessage(history[newIndex])
|
||||||
|
}
|
||||||
|
}, [history, historyIndex])
|
||||||
|
|
||||||
|
// Handle keyboard shortcuts
|
||||||
|
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.ctrlKey || e.metaKey) {
|
||||||
|
if (e.key === 'z') {
|
||||||
|
e.preventDefault()
|
||||||
|
if (e.shiftKey) {
|
||||||
|
redo()
|
||||||
|
} else {
|
||||||
|
undo()
|
||||||
|
}
|
||||||
|
} else if (e.key === 'y') {
|
||||||
|
e.preventDefault()
|
||||||
|
redo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [undo, redo])
|
||||||
|
|
||||||
|
// Filter marathons based on search and status
|
||||||
|
const filteredMarathons = useMemo(() => {
|
||||||
|
return marathons.filter(m => {
|
||||||
|
const matchesSearch = !marathonSearch ||
|
||||||
|
m.title.toLowerCase().includes(marathonSearch.toLowerCase())
|
||||||
|
const matchesStatus = statusFilter === 'all' || m.status === statusFilter
|
||||||
|
return matchesSearch && matchesStatus
|
||||||
|
})
|
||||||
|
}, [marathons, marathonSearch, statusFilter])
|
||||||
|
|
||||||
|
const selectedMarathon = marathons.find(m => m.id === marathonId)
|
||||||
|
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (!message.trim()) {
|
if (!message.trim()) {
|
||||||
toast.error('Введите сообщение')
|
toast.error('Введите сообщение')
|
||||||
@@ -63,6 +174,39 @@ export function AdminBroadcastPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
const config = {
|
||||||
|
active: { label: 'Активен', class: 'bg-green-500/20 text-green-400 border-green-500/30' },
|
||||||
|
preparing: { label: 'Подготовка', class: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' },
|
||||||
|
finished: { label: 'Завершён', class: 'bg-gray-500/20 text-gray-400 border-gray-500/30' },
|
||||||
|
}
|
||||||
|
const c = config[status as keyof typeof config] || config.finished
|
||||||
|
return (
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded border ${c.class}`}>
|
||||||
|
{c.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertTag = (openTag: string, closeTag: string) => {
|
||||||
|
const textarea = textareaRef.current
|
||||||
|
if (!textarea) return
|
||||||
|
|
||||||
|
const start = textarea.selectionStart
|
||||||
|
const end = textarea.selectionEnd
|
||||||
|
const selectedText = message.substring(start, end)
|
||||||
|
const newText = message.substring(0, start) + openTag + selectedText + closeTag + message.substring(end)
|
||||||
|
|
||||||
|
handleMessageChange(newText)
|
||||||
|
|
||||||
|
// Restore cursor position
|
||||||
|
setTimeout(() => {
|
||||||
|
textarea.focus()
|
||||||
|
const newCursorPos = start + openTag.length + selectedText.length
|
||||||
|
textarea.setSelectionRange(newCursorPos, newCursorPos)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -73,7 +217,9 @@ export function AdminBroadcastPage() {
|
|||||||
<h1 className="text-2xl font-bold text-white">Рассылка уведомлений</h1>
|
<h1 className="text-2xl font-bold text-white">Рассылка уведомлений</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-2xl space-y-6">
|
<div className="flex gap-6">
|
||||||
|
{/* Left Column - Editor */}
|
||||||
|
<div className="flex-1 space-y-6">
|
||||||
{/* Target Selection */}
|
{/* Target Selection */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="block text-sm font-medium text-gray-300">
|
<label className="block text-sm font-medium text-gray-300">
|
||||||
@@ -110,49 +256,216 @@ export function AdminBroadcastPage() {
|
|||||||
|
|
||||||
{/* Marathon Selection */}
|
{/* Marathon Selection */}
|
||||||
{targetType === 'marathon' && (
|
{targetType === 'marathon' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<label className="block text-sm font-medium text-gray-300">
|
<label className="block text-sm font-medium text-gray-300">
|
||||||
Выберите марафон
|
Выберите марафон
|
||||||
</label>
|
</label>
|
||||||
{loadingMarathons ? (
|
|
||||||
<div className="animate-pulse bg-dark-700 h-12 rounded-xl" />
|
{/* Search and Filter */}
|
||||||
) : (
|
<div className="flex gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск по названию..."
|
||||||
|
value={marathonSearch}
|
||||||
|
onChange={(e) => setMarathonSearch(e.target.value)}
|
||||||
|
className="w-full bg-dark-700/50 border border-dark-600 rounded-lg pl-9 pr-3 py-2 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<select
|
<select
|
||||||
value={marathonId || ''}
|
value={statusFilter}
|
||||||
onChange={(e) => setMarathonId(Number(e.target.value) || null)}
|
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
|
||||||
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"
|
className="bg-dark-700/50 border border-dark-600 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||||
>
|
>
|
||||||
<option value="">Выберите марафон...</option>
|
<option value="all">Все статусы</option>
|
||||||
{marathons.map((m) => (
|
<option value="active">Активные</option>
|
||||||
<option key={m.id} value={m.id}>
|
<option value="preparing">Подготовка</option>
|
||||||
{m.title} ({m.participants_count} участников)
|
<option value="finished">Завершённые</option>
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Marathon List */}
|
||||||
|
{loadingMarathons ? (
|
||||||
|
<div className="animate-pulse bg-dark-700 h-32 rounded-xl" />
|
||||||
|
) : (
|
||||||
|
<div className="max-h-48 overflow-y-auto rounded-xl border border-dark-600 bg-dark-800/50">
|
||||||
|
{filteredMarathons.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-gray-500 text-sm">
|
||||||
|
{marathons.length === 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p>Не удалось загрузить марафоны</p>
|
||||||
|
<button
|
||||||
|
onClick={loadMarathons}
|
||||||
|
className="text-accent-400 hover:underline"
|
||||||
|
>
|
||||||
|
Повторить загрузку
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'Марафоны не найдены по фильтру'
|
||||||
)}
|
)}
|
||||||
{marathons.length === 0 && !loadingMarathons && (
|
</div>
|
||||||
<p className="text-sm text-gray-500">Нет активных марафонов</p>
|
) : (
|
||||||
|
filteredMarathons.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => setMarathonId(marathonId === m.id ? null : m.id)}
|
||||||
|
className={`w-full flex items-center justify-between p-3 text-left border-b border-dark-600 last:border-b-0 transition-colors ${
|
||||||
|
marathonId === m.id
|
||||||
|
? 'bg-accent-500/20 text-white'
|
||||||
|
: 'hover:bg-dark-700/50 text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<span className="truncate font-medium">{m.title}</span>
|
||||||
|
{getStatusBadge(m.status)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500 flex-shrink-0 ml-2">
|
||||||
|
{m.participants_count} уч.
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Message */}
|
{/* Selected Marathon Info */}
|
||||||
|
{selectedMarathon && (
|
||||||
|
<div className="p-3 bg-accent-500/10 border border-accent-500/30 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-accent-400">
|
||||||
|
<Trophy className="w-4 h-4" />
|
||||||
|
<span className="font-medium">{selectedMarathon.title}</span>
|
||||||
|
{getStatusBadge(selectedMarathon.status)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setMarathonId(null)}
|
||||||
|
className="p-1 text-gray-400 hover:text-white rounded hover:bg-dark-600/50 transition-colors"
|
||||||
|
title="Отменить выбор"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
|
{selectedMarathon.participants_count} участников получат сообщение
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Message with Tag Toolbar */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<label className="block text-sm font-medium text-gray-300">
|
<label className="block text-sm font-medium text-gray-300">
|
||||||
Сообщение
|
Сообщение
|
||||||
</label>
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTagsHelp(!showTagsHelp)}
|
||||||
|
className="text-xs text-gray-500 hover:text-accent-400 flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronDown className={`w-3 h-3 transition-transform ${showTagsHelp ? 'rotate-180' : ''}`} />
|
||||||
|
Справка по тегам
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Tag Buttons */}
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => insertTag('<b>', '</b>')}
|
||||||
|
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded font-bold transition-colors"
|
||||||
|
title="Жирный"
|
||||||
|
>
|
||||||
|
B
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => insertTag('<i>', '</i>')}
|
||||||
|
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded italic transition-colors"
|
||||||
|
title="Курсив"
|
||||||
|
>
|
||||||
|
I
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => insertTag('<u>', '</u>')}
|
||||||
|
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded underline transition-colors"
|
||||||
|
title="Подчёркнутый"
|
||||||
|
>
|
||||||
|
U
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => insertTag('<s>', '</s>')}
|
||||||
|
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded line-through transition-colors"
|
||||||
|
title="Зачёркнутый"
|
||||||
|
>
|
||||||
|
S
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => insertTag('<code>', '</code>')}
|
||||||
|
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded font-mono transition-colors"
|
||||||
|
title="Код"
|
||||||
|
>
|
||||||
|
{'</>'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => insertTag('<pre>', '</pre>')}
|
||||||
|
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded font-mono transition-colors"
|
||||||
|
title="Блок кода"
|
||||||
|
>
|
||||||
|
PRE
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => insertTag('<a href="URL">', '</a>')}
|
||||||
|
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded transition-colors"
|
||||||
|
title="Ссылка"
|
||||||
|
>
|
||||||
|
🔗
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => insertTag('<tg-spoiler>', '</tg-spoiler>')}
|
||||||
|
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded transition-colors"
|
||||||
|
title="Спойлер"
|
||||||
|
>
|
||||||
|
👁️
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => insertTag('<blockquote>', '</blockquote>')}
|
||||||
|
className="px-2 py-1 text-xs bg-dark-700 hover:bg-dark-600 text-gray-300 rounded transition-colors"
|
||||||
|
title="Цитата"
|
||||||
|
>
|
||||||
|
❝
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags Help */}
|
||||||
|
{showTagsHelp && (
|
||||||
|
<div className="p-3 bg-dark-800 border border-dark-600 rounded-lg text-xs space-y-2">
|
||||||
|
<p className="text-gray-400 font-medium mb-2">Поддерживаемые теги Telegram:</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{TELEGRAM_TAGS.map((t) => (
|
||||||
|
<div key={t.tag} className="flex items-center gap-2">
|
||||||
|
<code className="text-neon-400 bg-dark-700 px-1.5 py-0.5 rounded">{t.tag}</code>
|
||||||
|
<span className="text-gray-500">— {t.description}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => handleMessageChange(e.target.value)}
|
||||||
rows={6}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Введите текст сообщения... (поддерживается HTML: <b>, <i>, <code>)"
|
rows={8}
|
||||||
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"
|
placeholder="Введите текст сообщения..."
|
||||||
|
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors resize-none font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-gray-500">
|
<p className="text-xs text-gray-600">
|
||||||
Поддерживается HTML: <b>, <i>, <code>, <a href>
|
Ctrl+Z — отмена • Ctrl+Shift+Z — повтор
|
||||||
</p>
|
</p>
|
||||||
<p className={`${message.length > 2000 ? 'text-red-400' : 'text-gray-500'}`}>
|
<p className={`text-xs ${message.length > 2000 ? 'text-red-400' : 'text-gray-500'}`}>
|
||||||
{message.length} / 2000
|
{message.length} / 2000
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,6 +498,120 @@ export function AdminBroadcastPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Preview */}
|
||||||
|
<div className="w-96 flex-shrink-0">
|
||||||
|
<div className="sticky top-6 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-gray-400">
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-medium">Предпросмотр</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Telegram-style Preview */}
|
||||||
|
<div className="bg-[#0e1621] rounded-2xl p-4 border border-dark-600 shadow-xl">
|
||||||
|
{/* Chat Header */}
|
||||||
|
<div className="flex items-center gap-3 pb-3 border-b border-dark-600 mb-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-accent-500 to-pink-500 flex items-center justify-center text-white font-bold">
|
||||||
|
M
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-medium text-sm">Marathon Bot</p>
|
||||||
|
<p className="text-gray-500 text-xs">бот</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message Bubble */}
|
||||||
|
<div className="bg-[#182533] rounded-2xl rounded-tl-md p-3 max-w-full">
|
||||||
|
{message.trim() ? (
|
||||||
|
<div
|
||||||
|
className="text-white text-sm break-words telegram-preview"
|
||||||
|
dangerouslySetInnerHTML={{ __html: telegramToHtml(message) }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500 text-sm italic">Введите сообщение...</p>
|
||||||
|
)}
|
||||||
|
<p className="text-gray-500 text-xs text-right mt-2">
|
||||||
|
{new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="mt-4 pt-3 border-t border-dark-600">
|
||||||
|
<div className="flex items-center gap-2 text-gray-500 text-xs">
|
||||||
|
<MessageSquare className="w-3 h-3" />
|
||||||
|
<span>
|
||||||
|
Так будет выглядеть сообщение в Telegram
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview Notes */}
|
||||||
|
<div className="text-xs text-gray-500 space-y-1">
|
||||||
|
<p>• Спойлеры (<code className="text-gray-400"><tg-spoiler></code>) показаны размытыми</p>
|
||||||
|
<p>• Цитаты (<code className="text-gray-400"><blockquote></code>) с вертикальной линией</p>
|
||||||
|
<p>• Ссылки будут кликабельны в Telegram</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom styles for Telegram preview */}
|
||||||
|
<style>{`
|
||||||
|
.telegram-preview b, .telegram-preview strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.telegram-preview i, .telegram-preview em {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.telegram-preview u {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.telegram-preview s, .telegram-preview strike, .telegram-preview del {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
.telegram-preview code {
|
||||||
|
font-family: monospace;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.telegram-preview pre {
|
||||||
|
font-family: monospace;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 4px 0;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.telegram-preview a {
|
||||||
|
color: #6ab2f2;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.telegram-preview a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.telegram-preview .tg-spoiler {
|
||||||
|
background: #333;
|
||||||
|
color: transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.telegram-preview .tg-spoiler:hover {
|
||||||
|
color: white;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.telegram-preview blockquote {
|
||||||
|
border-left: 3px solid #6ab2f2;
|
||||||
|
padding-left: 12px;
|
||||||
|
margin: 8px 0;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,312 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { adminApi } from '@/api'
|
|
||||||
import type { AdminDispute } from '@/types'
|
|
||||||
import { GlassCard, NeonButton } from '@/components/ui'
|
|
||||||
import { useToast } from '@/store/toast'
|
|
||||||
import {
|
|
||||||
AlertTriangle, Loader2, CheckCircle, XCircle, Clock,
|
|
||||||
ThumbsUp, ThumbsDown, User, Trophy, ExternalLink
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
|
|
||||||
export function AdminDisputesPage() {
|
|
||||||
const toast = useToast()
|
|
||||||
const [disputes, setDisputes] = useState<AdminDispute[]>([])
|
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
|
||||||
const [filter, setFilter] = useState<'pending' | 'open' | 'all'>('pending')
|
|
||||||
const [resolvingId, setResolvingId] = useState<number | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadDisputes()
|
|
||||||
}, [filter])
|
|
||||||
|
|
||||||
const loadDisputes = async () => {
|
|
||||||
setIsLoading(true)
|
|
||||||
try {
|
|
||||||
const data = await adminApi.listDisputes(filter)
|
|
||||||
setDisputes(data)
|
|
||||||
} catch (err) {
|
|
||||||
toast.error('Не удалось загрузить оспаривания')
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleResolve = async (disputeId: number, isValid: boolean) => {
|
|
||||||
setResolvingId(disputeId)
|
|
||||||
try {
|
|
||||||
await adminApi.resolveDispute(disputeId, isValid)
|
|
||||||
toast.success(isValid ? 'Пруф подтверждён' : 'Пруф отклонён')
|
|
||||||
await loadDisputes()
|
|
||||||
} catch (err) {
|
|
||||||
toast.error('Не удалось разрешить диспут')
|
|
||||||
} finally {
|
|
||||||
setResolvingId(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
return new Date(dateString).toLocaleString('ru-RU', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const getTimeRemaining = (expiresAt: string) => {
|
|
||||||
const now = new Date()
|
|
||||||
const expires = new Date(expiresAt)
|
|
||||||
const diff = expires.getTime() - now.getTime()
|
|
||||||
|
|
||||||
if (diff <= 0) return 'Истекло'
|
|
||||||
|
|
||||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
|
||||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
|
||||||
|
|
||||||
return `${hours}ч ${minutes}м`
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'open':
|
|
||||||
return (
|
|
||||||
<span className="px-2 py-1 bg-blue-500/20 text-blue-400 rounded text-xs font-medium flex items-center gap-1">
|
|
||||||
<Clock className="w-3 h-3" />
|
|
||||||
Голосование
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
case 'pending_admin':
|
|
||||||
return (
|
|
||||||
<span className="px-2 py-1 bg-orange-500/20 text-orange-400 rounded text-xs font-medium flex items-center gap-1">
|
|
||||||
<AlertTriangle className="w-3 h-3" />
|
|
||||||
Ожидает решения
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
case 'valid':
|
|
||||||
return (
|
|
||||||
<span className="px-2 py-1 bg-green-500/20 text-green-400 rounded text-xs font-medium flex items-center gap-1">
|
|
||||||
<CheckCircle className="w-3 h-3" />
|
|
||||||
Валидно
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
case 'invalid':
|
|
||||||
return (
|
|
||||||
<span className="px-2 py-1 bg-red-500/20 text-red-400 rounded text-xs font-medium flex items-center gap-1">
|
|
||||||
<XCircle className="w-3 h-3" />
|
|
||||||
Невалидно
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingCount = disputes.filter(d => d.status === 'pending_admin').length
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 to-orange-400">
|
|
||||||
Оспаривания
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-400 mt-1">
|
|
||||||
Управление диспутами и проверка пруфов
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{pendingCount > 0 && (
|
|
||||||
<div className="px-4 py-2 bg-orange-500/20 border border-orange-500/30 rounded-xl">
|
|
||||||
<span className="text-orange-400 font-semibold">{pendingCount}</span>
|
|
||||||
<span className="text-gray-400 ml-2">ожида{pendingCount === 1 ? 'ет' : 'ют'} решения</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
className={`px-4 py-2 rounded-lg font-medium transition-all ${
|
|
||||||
filter === 'pending'
|
|
||||||
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
|
|
||||||
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
|
|
||||||
}`}
|
|
||||||
onClick={() => setFilter('pending')}
|
|
||||||
>
|
|
||||||
Ожидают решения
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`px-4 py-2 rounded-lg font-medium transition-all ${
|
|
||||||
filter === 'open'
|
|
||||||
? 'bg-blue-500/20 text-blue-400 border border-blue-500/30'
|
|
||||||
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
|
|
||||||
}`}
|
|
||||||
onClick={() => setFilter('open')}
|
|
||||||
>
|
|
||||||
Голосование
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`px-4 py-2 rounded-lg font-medium transition-all ${
|
|
||||||
filter === 'all'
|
|
||||||
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30'
|
|
||||||
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
|
|
||||||
}`}
|
|
||||||
onClick={() => setFilter('all')}
|
|
||||||
>
|
|
||||||
Все
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Loading */}
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-accent-500" />
|
|
||||||
</div>
|
|
||||||
) : disputes.length === 0 ? (
|
|
||||||
<GlassCard className="text-center py-12">
|
|
||||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-green-500/10 border border-green-500/30 flex items-center justify-center">
|
|
||||||
<CheckCircle className="w-8 h-8 text-green-400" />
|
|
||||||
</div>
|
|
||||||
<p className="text-gray-400">
|
|
||||||
{filter === 'pending' ? 'Нет оспариваний, ожидающих решения' :
|
|
||||||
filter === 'open' ? 'Нет оспариваний в стадии голосования' :
|
|
||||||
'Нет оспариваний'}
|
|
||||||
</p>
|
|
||||||
</GlassCard>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{disputes.map((dispute) => (
|
|
||||||
<GlassCard
|
|
||||||
key={dispute.id}
|
|
||||||
className={
|
|
||||||
dispute.status === 'pending_admin' ? 'border-orange-500/30' :
|
|
||||||
dispute.status === 'open' ? 'border-blue-500/30' : ''
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
{/* Left side - Info */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center shrink-0">
|
|
||||||
<AlertTriangle className="w-5 h-5 text-yellow-400" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h3 className="text-white font-semibold truncate">
|
|
||||||
{dispute.challenge_title}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
|
||||||
<Trophy className="w-3 h-3" />
|
|
||||||
<span className="truncate">{dispute.marathon_title}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Participants */}
|
|
||||||
<div className="flex flex-wrap gap-4 mb-3 text-sm">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<User className="w-4 h-4 text-gray-500" />
|
|
||||||
<span className="text-gray-400">Автор:</span>
|
|
||||||
<span className="text-white">{dispute.participant_nickname}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<AlertTriangle className="w-4 h-4 text-gray-500" />
|
|
||||||
<span className="text-gray-400">Оспорил:</span>
|
|
||||||
<span className="text-white">{dispute.raised_by_nickname}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Reason */}
|
|
||||||
<div className="p-3 bg-dark-700/50 rounded-lg border border-dark-600 mb-3">
|
|
||||||
<p className="text-sm text-gray-400 mb-1">Причина:</p>
|
|
||||||
<p className="text-white text-sm">{dispute.reason}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Votes & Time */}
|
|
||||||
<div className="flex items-center gap-4 text-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex items-center gap-1 text-green-400">
|
|
||||||
<ThumbsUp className="w-4 h-4" />
|
|
||||||
<span className="font-medium">{dispute.votes_valid}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-gray-600">/</span>
|
|
||||||
<div className="flex items-center gap-1 text-red-400">
|
|
||||||
<ThumbsDown className="w-4 h-4" />
|
|
||||||
<span className="font-medium">{dispute.votes_invalid}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-gray-600">•</span>
|
|
||||||
<span className="text-gray-400">{formatDate(dispute.created_at)}</span>
|
|
||||||
{dispute.status === 'open' && (
|
|
||||||
<>
|
|
||||||
<span className="text-gray-600">•</span>
|
|
||||||
<span className="text-yellow-400 flex items-center gap-1">
|
|
||||||
<Clock className="w-3 h-3" />
|
|
||||||
{getTimeRemaining(dispute.expires_at)}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right side - Status & Actions */}
|
|
||||||
<div className="flex flex-col items-end gap-3 shrink-0">
|
|
||||||
{getStatusBadge(dispute.status)}
|
|
||||||
|
|
||||||
{/* Link to assignment */}
|
|
||||||
{dispute.assignment_id && (
|
|
||||||
<Link
|
|
||||||
to={`/assignments/${dispute.assignment_id}`}
|
|
||||||
className="text-xs text-accent-400 hover:text-accent-300 flex items-center gap-1"
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-3 h-3" />
|
|
||||||
Открыть
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Resolution buttons - show for open and pending_admin */}
|
|
||||||
{(dispute.status === 'open' || dispute.status === 'pending_admin') && (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{/* Vote recommendation for pending disputes */}
|
|
||||||
{dispute.status === 'pending_admin' && (
|
|
||||||
<div className="text-xs text-gray-400 text-right mb-1">
|
|
||||||
Рекомендация: {dispute.votes_invalid > dispute.votes_valid ? (
|
|
||||||
<span className="text-red-400">невалидно</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-green-400">валидно</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<NeonButton
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="border-green-500/50 text-green-400 hover:bg-green-500/10"
|
|
||||||
onClick={() => handleResolve(dispute.id, true)}
|
|
||||||
isLoading={resolvingId === dispute.id}
|
|
||||||
disabled={resolvingId !== null}
|
|
||||||
icon={<CheckCircle className="w-4 h-4" />}
|
|
||||||
>
|
|
||||||
Валидно
|
|
||||||
</NeonButton>
|
|
||||||
<NeonButton
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="border-red-500/50 text-red-400 hover:bg-red-500/10"
|
|
||||||
onClick={() => handleResolve(dispute.id, false)}
|
|
||||||
isLoading={resolvingId === dispute.id}
|
|
||||||
disabled={resolvingId !== null}
|
|
||||||
icon={<XCircle className="w-4 h-4" />}
|
|
||||||
>
|
|
||||||
Невалидно
|
|
||||||
</NeonButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</GlassCard>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -12,15 +12,13 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Lock,
|
Lock
|
||||||
AlertTriangle
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/admin', icon: LayoutDashboard, label: 'Дашборд', end: true },
|
{ to: '/admin', icon: LayoutDashboard, label: 'Дашборд', end: true },
|
||||||
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
|
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
|
||||||
{ to: '/admin/marathons', icon: Trophy, label: 'Марафоны' },
|
{ to: '/admin/marathons', icon: Trophy, label: 'Марафоны' },
|
||||||
{ to: '/admin/disputes', icon: AlertTriangle, label: 'Оспаривания' },
|
|
||||||
{ to: '/admin/logs', icon: ScrollText, label: 'Логи' },
|
{ to: '/admin/logs', icon: ScrollText, label: 'Логи' },
|
||||||
{ to: '/admin/broadcast', icon: Send, label: 'Рассылка' },
|
{ to: '/admin/broadcast', icon: Send, label: 'Рассылка' },
|
||||||
{ to: '/admin/content', icon: FileText, label: 'Контент' },
|
{ to: '/admin/content', icon: FileText, label: 'Контент' },
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { AdminUser, UserRole } from '@/types'
|
|||||||
import { useToast } from '@/store/toast'
|
import { useToast } from '@/store/toast'
|
||||||
import { useConfirm } from '@/store/confirm'
|
import { useConfirm } from '@/store/confirm'
|
||||||
import { NeonButton } from '@/components/ui'
|
import { NeonButton } from '@/components/ui'
|
||||||
import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X, KeyRound } from 'lucide-react'
|
import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X, KeyRound, Bell, BellOff } from 'lucide-react'
|
||||||
|
|
||||||
export function AdminUsersPage() {
|
export function AdminUsersPage() {
|
||||||
const [users, setUsers] = useState<AdminUser[]>([])
|
const [users, setUsers] = useState<AdminUser[]>([])
|
||||||
@@ -195,6 +195,7 @@ export function AdminUsersPage() {
|
|||||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Роль</th>
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Роль</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Telegram</th>
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Telegram</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Марафоны</th>
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Марафоны</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Уведомления</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th>
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Действия</th>
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -202,13 +203,13 @@ export function AdminUsersPage() {
|
|||||||
<tbody className="divide-y divide-dark-600">
|
<tbody className="divide-y divide-dark-600">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-4 py-8 text-center">
|
<td colSpan={9} 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" />
|
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : users.length === 0 ? (
|
) : users.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
|
<td colSpan={9} className="px-4 py-8 text-center text-gray-400">
|
||||||
Пользователи не найдены
|
Пользователи не найдены
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -236,6 +237,30 @@ export function AdminUsersPage() {
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-400">{user.marathons_count}</td>
|
<td className="px-4 py-3 text-sm text-gray-400">{user.marathons_count}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{user.telegram_id ? (
|
||||||
|
<div className="flex items-center gap-1" title={`События: ${user.notify_events ? 'вкл' : 'выкл'}, Споры: ${user.notify_disputes ? 'вкл' : 'выкл'}, Модерация: ${user.notify_moderation ? 'вкл' : 'выкл'}`}>
|
||||||
|
{user.notify_events && user.notify_disputes && user.notify_moderation ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-lg bg-green-500/20 text-green-400 border border-green-500/30">
|
||||||
|
<Bell className="w-3 h-3" />
|
||||||
|
Все
|
||||||
|
</span>
|
||||||
|
) : !user.notify_events && !user.notify_disputes && !user.notify_moderation ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-lg bg-red-500/20 text-red-400 border border-red-500/30">
|
||||||
|
<BellOff className="w-3 h-3" />
|
||||||
|
Откл
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-lg bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">
|
||||||
|
<Bell className="w-3 h-3" />
|
||||||
|
Частично
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-600">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{user.is_banned ? (
|
{user.is_banned ? (
|
||||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-red-500/20 text-red-400 border border-red-500/30">
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg bg-red-500/20 text-red-400 border border-red-500/30">
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ export { AdminLayout } from './AdminLayout'
|
|||||||
export { AdminDashboardPage } from './AdminDashboardPage'
|
export { AdminDashboardPage } from './AdminDashboardPage'
|
||||||
export { AdminUsersPage } from './AdminUsersPage'
|
export { AdminUsersPage } from './AdminUsersPage'
|
||||||
export { AdminMarathonsPage } from './AdminMarathonsPage'
|
export { AdminMarathonsPage } from './AdminMarathonsPage'
|
||||||
export { AdminDisputesPage } from './AdminDisputesPage'
|
|
||||||
export { AdminLogsPage } from './AdminLogsPage'
|
export { AdminLogsPage } from './AdminLogsPage'
|
||||||
export { AdminBroadcastPage } from './AdminBroadcastPage'
|
export { AdminBroadcastPage } from './AdminBroadcastPage'
|
||||||
export { AdminContentPage } from './AdminContentPage'
|
export { AdminContentPage } from './AdminContentPage'
|
||||||
|
|||||||
@@ -18,6 +18,23 @@ export interface User extends UserPublic {
|
|||||||
telegram_username?: string | null // Only visible to self
|
telegram_username?: string | null // Only visible to self
|
||||||
telegram_first_name?: string | null // Only visible to self
|
telegram_first_name?: string | null // Only visible to self
|
||||||
telegram_last_name?: string | null // Only visible to self
|
telegram_last_name?: string | null // Only visible to self
|
||||||
|
// Notification settings
|
||||||
|
notify_events?: boolean
|
||||||
|
notify_disputes?: boolean
|
||||||
|
notify_moderation?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification settings
|
||||||
|
export interface NotificationSettings {
|
||||||
|
notify_events: boolean
|
||||||
|
notify_disputes: boolean
|
||||||
|
notify_moderation: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationSettingsUpdate {
|
||||||
|
notify_events?: boolean
|
||||||
|
notify_disputes?: boolean
|
||||||
|
notify_moderation?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenResponse {
|
export interface TokenResponse {
|
||||||
@@ -472,6 +489,10 @@ export interface AdminUser {
|
|||||||
banned_at: string | null
|
banned_at: string | null
|
||||||
banned_until: string | null // null = permanent ban
|
banned_until: string | null // null = permanent ban
|
||||||
ban_reason: string | null
|
ban_reason: string | null
|
||||||
|
// Notification settings
|
||||||
|
notify_events: boolean
|
||||||
|
notify_disputes: boolean
|
||||||
|
notify_moderation: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminMarathon {
|
export interface AdminMarathon {
|
||||||
|
|||||||
Reference in New Issue
Block a user