Compare commits

...

4 Commits

Author SHA1 Message Date
81d992abe6 remake send push systems 2026-01-04 04:16:54 +07:00
9014d5d79d Add notification status to users table in AP 2026-01-04 03:42:11 +07:00
18ffff5473 Fix games list 2026-01-04 03:17:17 +07:00
475e2cf4cd Add notification settings 2026-01-04 02:47:38 +07:00
23 changed files with 1160 additions and 545 deletions

View File

@@ -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
@@ -14,6 +14,7 @@ help:
@echo " make logs - Show logs (all services)"
@echo " make logs-b - Show backend logs"
@echo " make logs-f - Show frontend logs"
@echo " make logs-bot - Show Telegram bot logs"
@echo ""
@echo " Build:"
@echo " make build - Build all containers (with cache)"
@@ -63,6 +64,9 @@ logs-b:
logs-f:
$(DC) logs -f frontend
logs-bot:
$(DC) logs -f bot
# Build
build:
$(DC) build

View 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')

View File

@@ -64,6 +64,28 @@ async def log_admin_action(
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])
async def list_users(
current_user: CurrentUser,
@@ -97,21 +119,7 @@ async def list_users(
marathons_count = await db.scalar(
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
)
response.append(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,
))
response.append(build_admin_user_response(user, marathons_count))
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)
)
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,
)
return build_admin_user_response(user, marathons_count)
@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)
)
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,
)
return build_admin_user_response(user, marathons_count)
@router.delete("/users/{user_id}", response_model=MessageResponse)
@@ -230,7 +210,7 @@ async def list_marathons(
current_user: CurrentUser,
db: DbSession,
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,
):
"""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)
)
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,
)
return build_admin_user_response(user, marathons_count)
@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)
)
return AdminUserResponse(
id=user.id,
login=user.login,
nickname=user.nickname,
role=user.role,
avatar_url=user.avatar_url,
telegram_id=user.telegram_id,
telegram_username=user.telegram_username,
marathons_count=marathons_count,
created_at=user.created_at.isoformat(),
is_banned=user.is_banned,
banned_at=None,
banned_until=None,
ban_reason=None,
)
return build_admin_user_response(user, marathons_count)
# ============ Reset Password ============
@@ -478,21 +430,7 @@ async def reset_user_password(
select(func.count()).select_from(Participant).where(Participant.user_id == user.id)
)
return AdminUserResponse(
id=user.id,
login=user.login,
nickname=user.nickname,
role=user.role,
avatar_url=user.avatar_url,
telegram_id=user.telegram_id,
telegram_username=user.telegram_username,
marathons_count=marathons_count,
created_at=user.created_at.isoformat(),
is_banned=user.is_banned,
banned_at=user.banned_at.isoformat() if user.banned_at else None,
banned_until=user.banned_until.isoformat() if user.banned_until else None,
ban_reason=user.ban_reason,
)
return build_admin_user_response(user, marathons_count)
# ============ Force Finish Marathon ============

View File

@@ -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])
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
result = await db.execute(select(Marathon).where(Marathon.id == marathon_id))
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:
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(
select(Challenge)
.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()
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)

View File

@@ -73,6 +73,21 @@ class TelegramStatsResponse(BaseModel):
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
@router.post("/generate-link-token", response_model=TelegramLinkToken)
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,
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)

View File

@@ -9,7 +9,8 @@ from app.models.assignment import AssignmentStatus
from app.models.marathon import MarathonStatus
from app.schemas import (
UserPublic, UserPrivate, UserUpdate, TelegramLink, MessageResponse,
PasswordChange, UserStats, UserProfilePublic,
PasswordChange, UserStats, UserProfilePublic, NotificationSettings,
NotificationSettingsUpdate,
)
from app.services.storage import storage_service
@@ -189,6 +190,32 @@ async def change_password(
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)
async def get_my_stats(current_user: CurrentUser, db: DbSession):
"""Получить свою статистику"""

View File

@@ -34,6 +34,11 @@ class User(Base):
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)
# 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
created_marathons: Mapped[list["Marathon"]] = relationship(
"Marathon",

View File

@@ -9,6 +9,8 @@ from app.schemas.user import (
PasswordChange,
UserStats,
UserProfilePublic,
NotificationSettings,
NotificationSettingsUpdate,
)
from app.schemas.marathon import (
MarathonCreate,
@@ -115,6 +117,8 @@ __all__ = [
"PasswordChange",
"UserStats",
"UserProfilePublic",
"NotificationSettings",
"NotificationSettingsUpdate",
# Marathon
"MarathonCreate",
"MarathonUpdate",

View File

@@ -27,6 +27,10 @@ class AdminUserResponse(BaseModel):
banned_at: str | None = None
banned_until: str | None = None # None = permanent
ban_reason: str | None = None
# Notification settings
notify_events: bool = True
notify_disputes: bool = True
notify_moderation: bool = True
class Config:
from_attributes = True

View File

@@ -47,6 +47,10 @@ class UserPrivate(UserPublic):
telegram_username: str | None = None
telegram_first_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):
@@ -83,3 +87,20 @@ class UserProfilePublic(BaseModel):
class Config:
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

View File

@@ -83,9 +83,15 @@ class TelegramNotifier:
db: AsyncSession,
marathon_id: int,
message: str,
exclude_user_id: int | None = None
exclude_user_id: int | None = None,
check_setting: str | None = None
) -> 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(
select(User)
.join(Participant, Participant.user_id == User.id)
@@ -100,6 +106,10 @@ class TelegramNotifier:
for user in users:
if exclude_user_id and user.id == exclude_user_id:
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):
sent_count += 1
@@ -113,7 +123,7 @@ class TelegramNotifier:
event_type: str,
marathon_title: str
) -> int:
"""Notify participants about event start."""
"""Notify participants about event start (respects notify_events setting)."""
event_messages = {
"golden_hour": f"🌟 <b>Начался Golden Hour</b> в «{marathon_title}»!\n\nВсе очки x1.5 в течение часа!",
"jackpot": f"🎰 <b>JACKPOT</b> в «{marathon_title}»!\n\nОчки x3 за следующий сложный челлендж!",
@@ -128,7 +138,9 @@ class TelegramNotifier:
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(
self,
@@ -137,7 +149,7 @@ class TelegramNotifier:
event_type: str,
marathon_title: str
) -> int:
"""Notify participants about event end."""
"""Notify participants about event end (respects notify_events setting)."""
event_names = {
"golden_hour": "Golden Hour",
"jackpot": "Jackpot",
@@ -150,7 +162,9 @@ class TelegramNotifier:
event_name = event_names.get(event_type, "Событие")
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(
self,
@@ -186,7 +200,14 @@ class TelegramNotifier:
challenge_title: str,
assignment_id: int
) -> 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}")
dispute_url = f"{settings.FRONTEND_URL}/assignments/{assignment_id}"
@@ -227,7 +248,14 @@ class TelegramNotifier:
challenge_title: str,
is_valid: 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:
message = (
f"❌ <b>Спор признан обоснованным</b>\n\n"
@@ -251,7 +279,14 @@ class TelegramNotifier:
marathon_title: str,
game_title: str
) -> 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 = (
f"✅ <b>Твоя игра одобрена!</b>\n\n"
f"Марафон: {marathon_title}\n"
@@ -267,7 +302,14 @@ class TelegramNotifier:
marathon_title: str,
game_title: str
) -> 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 = (
f"❌ <b>Твоя игра отклонена</b>\n\n"
f"Марафон: {marathon_title}\n"
@@ -284,7 +326,14 @@ class TelegramNotifier:
game_title: str,
challenge_title: str
) -> 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 = (
f"✅ <b>Твой челлендж одобрен!</b>\n\n"
f"Марафон: {marathon_title}\n"
@@ -302,7 +351,14 @@ class TelegramNotifier:
game_title: str,
challenge_title: str
) -> 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 = (
f"❌ <b>Твой челлендж отклонён</b>\n\n"
f"Марафон: {marathon_title}\n"

View File

@@ -3,7 +3,7 @@ from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery
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
router = Router()
@@ -197,15 +197,66 @@ async def cmd_settings(message: Message):
)
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(
"<b>⚙️ Настройки</b>\n\n"
"Управление уведомлениями будет доступно в следующем обновлении.\n\n"
"Сейчас ты получаешь все уведомления:\n"
"• 🌟 События (Golden Hour, Jackpot и др.)\n"
"• 🚀 Старт/финиш марафонов\n"
"• ⚠️ Споры по заданиям\n\n"
"Команды:\n"
"/unlink - Отвязать аккаунт\n"
"/status - Проверить привязку",
"<b>⚙️ Настройки уведомлений</b>\n\n"
"Нажми на категорию, чтобы включить/выключить:\n\n"
"✅ — уведомления включены\n"
"❌ — уведомления выключены\n\n"
"<i>Уведомления о старте/финише марафонов и коды безопасности нельзя отключить.</i>",
reply_markup=get_settings_keyboard(settings)
)
@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()
)
await callback.answer()

View File

@@ -40,3 +40,45 @@ def get_marathon_details_keyboard(marathon_id: int) -> InlineKeyboardMarkup:
]
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)

View File

@@ -124,6 +124,22 @@ class APIClient:
"""Get user's overall statistics."""
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):
"""Close the HTTP session."""
if self._session and not self._session.closed:

View File

@@ -32,7 +32,6 @@ import {
AdminDashboardPage,
AdminUsersPage,
AdminMarathonsPage,
AdminDisputesPage,
AdminLogsPage,
AdminBroadcastPage,
AdminContentPage,
@@ -209,7 +208,6 @@ function App() {
<Route index element={<AdminDashboardPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="marathons" element={<AdminMarathonsPage />} />
<Route path="disputes" element={<AdminDisputesPage />} />
<Route path="logs" element={<AdminLogsPage />} />
<Route path="broadcast" element={<AdminBroadcastPage />} />
<Route path="content" element={<AdminContentPage />} />

View File

@@ -1,5 +1,5 @@
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 {
nickname: string
@@ -48,4 +48,16 @@ export const usersApi = {
})
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
},
}

View File

@@ -12,7 +12,8 @@ import {
import {
User, Camera, Trophy, Target, CheckCircle, Flame,
Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
Eye, EyeOff, Save, KeyRound, Shield
Eye, EyeOff, Save, KeyRound, Shield, Bell, Sparkles,
AlertTriangle, FileCheck
} from 'lucide-react'
// Schemas
@@ -51,6 +52,12 @@ export function ProfilePage() {
const [isPolling, setIsPolling] = useState(false)
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)
// 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 displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
@@ -544,6 +574,109 @@ export function ProfilePage() {
</form>
)}
</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>
)
}

View File

@@ -1,9 +1,34 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import { adminApi } from '@/api'
import type { AdminMarathon } from '@/types'
import { useToast } from '@/store/toast'
import { NeonButton } from '@/components/ui'
import { Send, Users, Trophy, AlertTriangle } from 'lucide-react'
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() {
const [message, setMessage] = useState('')
@@ -12,11 +37,26 @@ export function AdminBroadcastPage() {
const [marathons, setMarathons] = useState<AdminMarathon[]>([])
const [sending, setSending] = 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()
// Load marathons on mount and when switching to marathon target
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()
}
}, [targetType])
@@ -24,15 +64,86 @@ export function AdminBroadcastPage() {
const loadMarathons = async () => {
setLoadingMarathons(true)
try {
const data = await adminApi.listMarathons(0, 100)
setMarathons(data.filter(m => m.status === 'active'))
const data = await adminApi.listMarathons(0, 200)
console.log('Loaded marathons:', data)
setMarathons(data)
} catch (err) {
console.error('Failed to load marathons:', err)
toast.error('Ошибка загрузки марафонов')
} finally {
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 () => {
if (!message.trim()) {
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 (
<div className="space-y-6">
{/* Header */}
@@ -73,118 +217,401 @@ export function AdminBroadcastPage() {
<h1 className="text-2xl font-bold text-white">Рассылка уведомлений</h1>
</div>
<div className="max-w-2xl space-y-6">
{/* Target Selection */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-300">
Кому отправить
</label>
<div className="flex gap-4">
<button
onClick={() => {
setTargetType('all')
setMarathonId(null)
}}
className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-xl border transition-all duration-200 ${
targetType === 'all'
? 'bg-accent-500/20 border-accent-500/50 text-accent-400 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
: 'bg-dark-700/50 border-dark-600 text-gray-400 hover:border-dark-500 hover:text-gray-300'
}`}
>
<Users className="w-5 h-5" />
<span className="font-medium">Всем пользователям</span>
</button>
<button
onClick={() => setTargetType('marathon')}
className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-xl border transition-all duration-200 ${
targetType === 'marathon'
? 'bg-accent-500/20 border-accent-500/50 text-accent-400 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
: 'bg-dark-700/50 border-dark-600 text-gray-400 hover:border-dark-500 hover:text-gray-300'
}`}
>
<Trophy className="w-5 h-5" />
<span className="font-medium">Участникам марафона</span>
</button>
</div>
</div>
{/* Marathon Selection */}
{targetType === 'marathon' && (
<div className="space-y-2">
<div className="flex gap-6">
{/* Left Column - Editor */}
<div className="flex-1 space-y-6">
{/* Target Selection */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-300">
Выберите марафон
Кому отправить
</label>
{loadingMarathons ? (
<div className="animate-pulse bg-dark-700 h-12 rounded-xl" />
) : (
<select
value={marathonId || ''}
onChange={(e) => setMarathonId(Number(e.target.value) || null)}
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-accent-500/50 transition-colors"
<div className="flex gap-4">
<button
onClick={() => {
setTargetType('all')
setMarathonId(null)
}}
className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-xl border transition-all duration-200 ${
targetType === 'all'
? 'bg-accent-500/20 border-accent-500/50 text-accent-400 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
: 'bg-dark-700/50 border-dark-600 text-gray-400 hover:border-dark-500 hover:text-gray-300'
}`}
>
<option value="">Выберите марафон...</option>
{marathons.map((m) => (
<option key={m.id} value={m.id}>
{m.title} ({m.participants_count} участников)
</option>
))}
</select>
)}
{marathons.length === 0 && !loadingMarathons && (
<p className="text-sm text-gray-500">Нет активных марафонов</p>
)}
<Users className="w-5 h-5" />
<span className="font-medium">Всем пользователям</span>
</button>
<button
onClick={() => setTargetType('marathon')}
className={`flex-1 flex items-center justify-center gap-3 p-4 rounded-xl border transition-all duration-200 ${
targetType === 'marathon'
? 'bg-accent-500/20 border-accent-500/50 text-accent-400 shadow-[0_0_15px_rgba(139,92,246,0.15)]'
: 'bg-dark-700/50 border-dark-600 text-gray-400 hover:border-dark-500 hover:text-gray-300'
}`}
>
<Trophy className="w-5 h-5" />
<span className="font-medium">Участникам марафона</span>
</button>
</div>
</div>
)}
{/* Message */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-300">
Сообщение
</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={6}
placeholder="Введите текст сообщения... (поддерживается HTML: <b>, <i>, <code>)"
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 transition-colors resize-none"
/>
<div className="flex items-center justify-between text-xs">
<p className="text-gray-500">
Поддерживается HTML: &lt;b&gt;, &lt;i&gt;, &lt;code&gt;, &lt;a href&gt;
</p>
<p className={`${message.length > 2000 ? 'text-red-400' : 'text-gray-500'}`}>
{message.length} / 2000
</p>
{/* Marathon Selection */}
{targetType === 'marathon' && (
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-300">
Выберите марафон
</label>
{/* 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
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
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="all">Все статусы</option>
<option value="active">Активные</option>
<option value="preparing">Подготовка</option>
<option value="finished">Завершённые</option>
</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>
) : (
'Марафоны не найдены по фильтру'
)}
</div>
) : (
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>
)}
{/* 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="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-300">
Сообщение
</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
ref={textareaRef}
value={message}
onChange={(e) => handleMessageChange(e.target.value)}
onKeyDown={handleKeyDown}
rows={8}
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">
<p className="text-xs text-gray-600">
Ctrl+Z отмена Ctrl+Shift+Z повтор
</p>
<p className={`text-xs ${message.length > 2000 ? 'text-red-400' : 'text-gray-500'}`}>
{message.length} / 2000
</p>
</div>
</div>
{/* Send Button */}
<NeonButton
size="lg"
color="purple"
onClick={handleSend}
disabled={sending || !message.trim() || message.length > 2000 || (targetType === 'marathon' && !marathonId)}
isLoading={sending}
icon={<Send className="w-5 h-5" />}
className="w-full"
>
{sending ? 'Отправка...' : 'Отправить рассылку'}
</NeonButton>
{/* Warning */}
<div className="glass rounded-xl p-4 border border-amber-500/20">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-amber-400 font-medium mb-1">Обратите внимание</p>
<p className="text-sm text-gray-400">
Сообщение будет отправлено только пользователям с привязанным Telegram.
Рассылка ограничена: 1 сообщение всем в минуту, 3 сообщения марафону в минуту.
</p>
</div>
</div>
</div>
</div>
{/* Send Button */}
<NeonButton
size="lg"
color="purple"
onClick={handleSend}
disabled={sending || !message.trim() || message.length > 2000 || (targetType === 'marathon' && !marathonId)}
isLoading={sending}
icon={<Send className="w-5 h-5" />}
className="w-full"
>
{sending ? 'Отправка...' : 'Отправить рассылку'}
</NeonButton>
{/* 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>
{/* Warning */}
<div className="glass rounded-xl p-4 border border-amber-500/20">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-amber-400 font-medium mb-1">Обратите внимание</p>
<p className="text-sm text-gray-400">
Сообщение будет отправлено только пользователям с привязанным Telegram.
Рассылка ограничена: 1 сообщение всем в минуту, 3 сообщения марафону в минуту.
</p>
{/* 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">&lt;tg-spoiler&gt;</code>) показаны размытыми</p>
<p> Цитаты (<code className="text-gray-400">&lt;blockquote&gt;</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>
)
}

View File

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

View File

@@ -12,15 +12,13 @@ import {
Shield,
MessageCircle,
Sparkles,
Lock,
AlertTriangle
Lock
} from 'lucide-react'
const navItems = [
{ to: '/admin', icon: LayoutDashboard, label: 'Дашборд', end: true },
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
{ to: '/admin/marathons', icon: Trophy, label: 'Марафоны' },
{ to: '/admin/disputes', icon: AlertTriangle, label: 'Оспаривания' },
{ to: '/admin/logs', icon: ScrollText, label: 'Логи' },
{ to: '/admin/broadcast', icon: Send, label: 'Рассылка' },
{ to: '/admin/content', icon: FileText, label: 'Контент' },

View File

@@ -4,7 +4,7 @@ import type { AdminUser, UserRole } from '@/types'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
import { NeonButton } from '@/components/ui'
import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X, KeyRound } from 'lucide-react'
import { Search, Ban, UserCheck, Shield, ShieldOff, ChevronLeft, ChevronRight, Users, X, KeyRound, Bell, BellOff } from 'lucide-react'
export function AdminUsersPage() {
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">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>
</tr>
@@ -202,13 +203,13 @@ export function AdminUsersPage() {
<tbody className="divide-y divide-dark-600">
{loading ? (
<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" />
</td>
</tr>
) : users.length === 0 ? (
<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>
</tr>
@@ -236,6 +237,30 @@ export function AdminUsersPage() {
)}
</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">
{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">

View File

@@ -2,7 +2,6 @@ export { AdminLayout } from './AdminLayout'
export { AdminDashboardPage } from './AdminDashboardPage'
export { AdminUsersPage } from './AdminUsersPage'
export { AdminMarathonsPage } from './AdminMarathonsPage'
export { AdminDisputesPage } from './AdminDisputesPage'
export { AdminLogsPage } from './AdminLogsPage'
export { AdminBroadcastPage } from './AdminBroadcastPage'
export { AdminContentPage } from './AdminContentPage'

View File

@@ -18,6 +18,23 @@ export interface User extends UserPublic {
telegram_username?: string | null // Only visible to self
telegram_first_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 {
@@ -472,6 +489,10 @@ export interface AdminUser {
banned_at: string | null
banned_until: string | null // null = permanent ban
ban_reason: string | null
// Notification settings
notify_events: boolean
notify_disputes: boolean
notify_moderation: boolean
}
export interface AdminMarathon {