Compare commits

...

3 Commits

Author SHA1 Message Date
895e296f44 Fixes 2025-12-16 22:43:03 +07:00
696dc714c4 Update GPT and add Profile 2025-12-16 22:12:12 +07:00
08b96fd1f7 Fix marathon deletion 2025-12-16 21:15:18 +07:00
20 changed files with 1130 additions and 109 deletions

View File

@@ -354,7 +354,8 @@ async def create_dispute(
db, db,
user_id=assignment.participant.user_id, user_id=assignment.participant.user_id,
marathon_title=marathon.title, marathon_title=marathon.title,
challenge_title=assignment.challenge.title challenge_title=assignment.challenge.title,
assignment_id=assignment_id
) )
# Load relationships for response # Load relationships for response

View File

@@ -14,12 +14,10 @@ from app.schemas import (
ChallengesPreviewResponse, ChallengesPreviewResponse,
ChallengesSaveRequest, ChallengesSaveRequest,
) )
from app.services.gpt import GPTService from app.services.gpt import gpt_service
router = APIRouter(tags=["challenges"]) router = APIRouter(tags=["challenges"])
gpt_service = GPTService()
async def get_challenge_or_404(db, challenge_id: int) -> Challenge: async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
result = await db.execute( result = await db.execute(
@@ -215,22 +213,35 @@ async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: Db
if not games: if not games:
raise HTTPException(status_code=400, detail="No approved games in marathon") raise HTTPException(status_code=400, detail="No approved games in marathon")
preview_challenges = [] # Filter games that don't have challenges yet
games_to_generate = []
game_map = {}
for game in games: for game in games:
# Check if game already has challenges
existing = await db.scalar( existing = await db.scalar(
select(Challenge.id).where(Challenge.game_id == game.id).limit(1) select(Challenge.id).where(Challenge.game_id == game.id).limit(1)
) )
if existing: if not existing:
continue # Skip if already has challenges games_to_generate.append({
"id": game.id,
"title": game.title,
"genre": game.genre
})
game_map[game.id] = game.title
try: if not games_to_generate:
challenges_data = await gpt_service.generate_challenges(game.title, game.genre) return ChallengesPreviewResponse(challenges=[])
# Generate challenges for all games in one API call
preview_challenges = []
try:
challenges_by_game = await gpt_service.generate_challenges(games_to_generate)
for game_id, challenges_data in challenges_by_game.items():
game_title = game_map.get(game_id, "Unknown")
for ch_data in challenges_data: for ch_data in challenges_data:
preview_challenges.append(ChallengePreview( preview_challenges.append(ChallengePreview(
game_id=game.id, game_id=game_id,
game_title=game.title, game_title=game_title,
title=ch_data.title, title=ch_data.title,
description=ch_data.description, description=ch_data.description,
type=ch_data.type, type=ch_data.type,
@@ -241,9 +252,8 @@ async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: Db
proof_hint=ch_data.proof_hint, proof_hint=ch_data.proof_hint,
)) ))
except Exception as e: except Exception as e:
# Log error but continue with other games print(f"Error generating challenges: {e}")
print(f"Error generating challenges for {game.title}: {e}")
return ChallengesPreviewResponse(challenges=preview_challenges) return ChallengesPreviewResponse(challenges=preview_challenges)

View File

@@ -1,10 +1,16 @@
from fastapi import APIRouter, HTTPException, status, UploadFile, File from fastapi import APIRouter, HTTPException, status, UploadFile, File
from sqlalchemy import select from sqlalchemy import select, func
from app.api.deps import DbSession, CurrentUser from app.api.deps import DbSession, CurrentUser
from app.core.config import settings from app.core.config import settings
from app.models import User from app.core.security import verify_password, get_password_hash
from app.schemas import UserPublic, UserUpdate, TelegramLink, MessageResponse from app.models import User, Participant, Assignment, Marathon
from app.models.assignment import AssignmentStatus
from app.models.marathon import MarathonStatus
from app.schemas import (
UserPublic, UserUpdate, TelegramLink, MessageResponse,
PasswordChange, UserStats, UserProfilePublic,
)
from app.services.storage import storage_service from app.services.storage import storage_service
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
@@ -125,3 +131,142 @@ async def unlink_telegram(
await db.commit() await db.commit()
return MessageResponse(message="Telegram account unlinked successfully") return MessageResponse(message="Telegram account unlinked successfully")
@router.post("/me/password", response_model=MessageResponse)
async def change_password(
data: PasswordChange,
current_user: CurrentUser,
db: DbSession,
):
"""Смена пароля текущего пользователя"""
if not verify_password(data.current_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Неверный текущий пароль",
)
if data.current_password == data.new_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Новый пароль должен отличаться от текущего",
)
current_user.password_hash = get_password_hash(data.new_password)
await db.commit()
return MessageResponse(message="Пароль успешно изменен")
@router.get("/me/stats", response_model=UserStats)
async def get_my_stats(current_user: CurrentUser, db: DbSession):
"""Получить свою статистику"""
return await _get_user_stats(current_user.id, db)
@router.get("/{user_id}/stats", response_model=UserStats)
async def get_user_stats(user_id: int, db: DbSession):
"""Получить статистику пользователя"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return await _get_user_stats(user_id, db)
@router.get("/{user_id}/profile", response_model=UserProfilePublic)
async def get_user_profile(user_id: int, db: DbSession):
"""Получить публичный профиль пользователя со статистикой"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
stats = await _get_user_stats(user_id, db)
return UserProfilePublic(
id=user.id,
nickname=user.nickname,
avatar_url=user.avatar_url,
created_at=user.created_at,
stats=stats,
)
async def _get_user_stats(user_id: int, db) -> UserStats:
"""Вспомогательная функция для подсчета статистики пользователя"""
# 1. Количество марафонов (участий)
marathons_result = await db.execute(
select(func.count(Participant.id))
.where(Participant.user_id == user_id)
)
marathons_count = marathons_result.scalar() or 0
# 2. Количество побед (1 место в завершенных марафонах)
wins_count = 0
user_participations = await db.execute(
select(Participant)
.join(Marathon, Marathon.id == Participant.marathon_id)
.where(
Participant.user_id == user_id,
Marathon.status == MarathonStatus.FINISHED.value
)
)
for participation in user_participations.scalars():
# Для каждого марафона проверяем, был ли пользователь первым
max_points_result = await db.execute(
select(func.max(Participant.total_points))
.where(Participant.marathon_id == participation.marathon_id)
)
max_points = max_points_result.scalar() or 0
if participation.total_points == max_points and max_points > 0:
# Проверяем что он единственный с такими очками (не ничья)
count_with_max = await db.execute(
select(func.count(Participant.id))
.where(
Participant.marathon_id == participation.marathon_id,
Participant.total_points == max_points
)
)
if count_with_max.scalar() == 1:
wins_count += 1
# 3. Выполненных заданий
completed_result = await db.execute(
select(func.count(Assignment.id))
.join(Participant, Participant.id == Assignment.participant_id)
.where(
Participant.user_id == user_id,
Assignment.status == AssignmentStatus.COMPLETED.value
)
)
completed_assignments = completed_result.scalar() or 0
# 4. Всего очков заработано
points_result = await db.execute(
select(func.coalesce(func.sum(Assignment.points_earned), 0))
.join(Participant, Participant.id == Assignment.participant_id)
.where(
Participant.user_id == user_id,
Assignment.status == AssignmentStatus.COMPLETED.value
)
)
total_points_earned = points_result.scalar() or 0
return UserStats(
marathons_count=marathons_count,
wins_count=wins_count,
completed_assignments=completed_assignments,
total_points_earned=total_points_earned,
)

View File

@@ -23,6 +23,9 @@ class Settings(BaseSettings):
TELEGRAM_BOT_USERNAME: str = "" TELEGRAM_BOT_USERNAME: str = ""
TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES: int = 10 TELEGRAM_LINK_TOKEN_EXPIRE_MINUTES: int = 10
# Frontend
FRONTEND_URL: str = "http://localhost:3000"
# Uploads # Uploads
UPLOAD_DIR: str = "uploads" UPLOAD_DIR: str = "uploads"
MAX_IMAGE_SIZE: int = 15 * 1024 * 1024 # 15 MB MAX_IMAGE_SIZE: int = 15 * 1024 * 1024 # 15 MB

View File

@@ -1,6 +1,13 @@
import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pathlib import Path from pathlib import Path

View File

@@ -35,4 +35,4 @@ class Assignment(Base):
participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments") participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments")
challenge: Mapped["Challenge"] = relationship("Challenge", back_populates="assignments") challenge: Mapped["Challenge"] = relationship("Challenge", back_populates="assignments")
event: Mapped["Event | None"] = relationship("Event", back_populates="assignments") event: Mapped["Event | None"] = relationship("Event", back_populates="assignments")
dispute: Mapped["Dispute | None"] = relationship("Dispute", back_populates="assignment", uselist=False) dispute: Mapped["Dispute | None"] = relationship("Dispute", back_populates="assignment", uselist=False, cascade="all, delete-orphan", passive_deletes=True)

View File

@@ -6,6 +6,9 @@ from app.schemas.user import (
UserWithTelegram, UserWithTelegram,
TokenResponse, TokenResponse,
TelegramLink, TelegramLink,
PasswordChange,
UserStats,
UserProfilePublic,
) )
from app.schemas.marathon import ( from app.schemas.marathon import (
MarathonCreate, MarathonCreate,
@@ -87,6 +90,9 @@ __all__ = [
"UserWithTelegram", "UserWithTelegram",
"TokenResponse", "TokenResponse",
"TelegramLink", "TelegramLink",
"PasswordChange",
"UserStats",
"UserProfilePublic",
# Marathon # Marathon
"MarathonCreate", "MarathonCreate",
"MarathonUpdate", "MarathonUpdate",

View File

@@ -58,3 +58,28 @@ class TokenResponse(BaseModel):
class TelegramLink(BaseModel): class TelegramLink(BaseModel):
telegram_id: int telegram_id: int
telegram_username: str | None = None telegram_username: str | None = None
class PasswordChange(BaseModel):
current_password: str = Field(..., min_length=6)
new_password: str = Field(..., min_length=6, max_length=100)
class UserStats(BaseModel):
"""Статистика пользователя по марафонам"""
marathons_count: int
wins_count: int
completed_assignments: int
total_points_earned: int
class UserProfilePublic(BaseModel):
"""Публичный профиль пользователя со статистикой"""
id: int
nickname: str
avatar_url: str | None = None
created_at: datetime
stats: UserStats
class Config:
from_attributes = True

View File

@@ -13,101 +13,131 @@ class GPTService:
async def generate_challenges( async def generate_challenges(
self, self,
game_title: str, games: list[dict]
game_genre: str | None = None ) -> dict[int, list[ChallengeGenerated]]:
) -> list[ChallengeGenerated]:
""" """
Generate challenges for a game using GPT. Generate challenges for multiple games in one API call.
Args: Args:
game_title: Name of the game games: List of dicts with keys: id, title, genre
game_genre: Optional genre of the game
Returns: Returns:
List of generated challenges Dict mapping game_id to list of generated challenges
""" """
genre_text = f" (жанр: {game_genre})" if game_genre else "" if not games:
return {}
prompt = f"""Ты — эксперт по видеоиграм. Сгенерируй 6 КОНКРЕТНЫХ челленджей для игры "{game_title}"{genre_text}. games_text = "\n".join([
f"- {g['title']}" + (f" (жанр: {g['genre']})" if g.get('genre') else "")
for g in games
])
ВАЖНО: Челленджи должны быть СПЕЦИФИЧНЫМИ для этой игры! prompt = f"""Ты — эксперт по видеоиграм. Сгенерируй по 6 КОНКРЕТНЫХ челленджей для каждой из следующих игр:
{games_text}
ВАЖНО: Челленджи должны быть СПЕЦИФИЧНЫМИ для каждой игры!
- Используй РЕАЛЬНЫЕ названия локаций, боссов, персонажей, миссий, уровней из игры - Используй РЕАЛЬНЫЕ названия локаций, боссов, персонажей, миссий, уровней из игры
- Основывайся на том, какие челленджи РЕАЛЬНО делают игроки в этой игре (спидраны, no-hit боссов, сбор коллекционных предметов и т.д.) - Основывайся на том, какие челленджи РЕАЛЬНО делают игроки в этой игре
- НЕ генерируй абстрактные челленджи типа "пройди уровень" или "убей 10 врагов" - НЕ генерируй абстрактные челленджи типа "пройди уровень" или "убей 10 врагов"
Примеры ХОРОШИХ челленджей: Требования по сложности ДЛЯ КАЖДОЙ ИГРЫ:
- Dark Souls: "Победи Орнштейна и Смоуга без призыва" / "Пройди Чумной город без отравления" - 2 лёгких (15-30 мин): простые задачи
- GTA V: "Получи золото в миссии «Ювелирное дело»" / "Выиграй уличную гонку на Vinewood" - 2 средних (1-2 часа): требуют навыка
- Hollow Knight: "Победи Хорнет без получения урона" / "Найди все грибные споры в Грибных пустошах" - 2 сложных (3+ часа): серьёзный челлендж
- Minecraft: "Убей Дракона Края за один визит в Энд" / "Построй работающую ферму железа"
Требования по сложности: Формат ответа — JSON с объектом где ключи это ТОЧНЫЕ названия игр, как они указаны в запросе:
- 2 лёгких (15-30 мин): простые задачи, знакомство с игрой {{
- 2 средних (1-2 часа): требуют навыка или исследования "Название игры 1": {{
- 2 сложных (3+ часа): серьёзный челлендж, достижения, полное прохождение "challenges": [
{{"title": "...", "description": "...", "type": "completion|no_death|speedrun|collection|achievement|challenge_run", "difficulty": "easy|medium|hard", "points": 50, "estimated_time": 30, "proof_type": "screenshot|video|steam", "proof_hint": "..."}}
]
}},
"Название игры 2": {{
"challenges": [...]
}}
}}
Формат ответа — JSON: points: easy=20-40, medium=45-75, hard=90-150
- title: название на русском (до 50 символов), конкретное и понятное Ответь ТОЛЬКО JSON."""
- description: что именно сделать (1-2 предложения), с деталями из игры
- type: completion | no_death | speedrun | collection | achievement | challenge_run
- difficulty: easy | medium | hard
- points: easy=20-40, medium=45-75, hard=90-150
- estimated_time: время в минутах
- proof_type: screenshot | video | steam
- proof_hint: ЧТО КОНКРЕТНО должно быть видно на скриншоте/видео (экран победы, достижение, локация и т.д.)
Ответь ТОЛЬКО JSON:
{{"challenges": [{{"title": "...", "description": "...", "type": "...", "difficulty": "...", "points": 50, "estimated_time": 30, "proof_type": "...", "proof_hint": "..."}}]}}"""
response = await self.client.chat.completions.create( response = await self.client.chat.completions.create(
model="gpt-5-mini", model="gpt-5-mini",
messages=[{"role": "user", "content": prompt}], messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"}, response_format={"type": "json_object"},
temperature=0.8,
max_tokens=2500,
) )
content = response.choices[0].message.content content = response.choices[0].message.content
data = json.loads(content) data = json.loads(content)
challenges = [] # Map game titles to IDs (case-insensitive, strip whitespace)
for ch in data.get("challenges", []): title_to_id = {g['title'].lower().strip(): g['id'] for g in games}
# Validate and normalize type
ch_type = ch.get("type", "completion")
if ch_type not in ["completion", "no_death", "speedrun", "collection", "achievement", "challenge_run"]:
ch_type = "completion"
# Validate difficulty # Also keep original titles for logging
difficulty = ch.get("difficulty", "medium") id_to_title = {g['id']: g['title'] for g in games}
if difficulty not in ["easy", "medium", "hard"]:
difficulty = "medium"
# Validate proof_type print(f"[GPT] Requested games: {[g['title'] for g in games]}")
proof_type = ch.get("proof_type", "screenshot") print(f"[GPT] Response keys: {list(data.keys())}")
if proof_type not in ["screenshot", "video", "steam"]:
proof_type = "screenshot"
# Validate points based on difficulty result = {}
points = ch.get("points", 30) for game_title, game_data in data.items():
if not isinstance(points, int) or points < 1: # Try exact match first, then case-insensitive
points = 30 game_id = title_to_id.get(game_title.lower().strip())
# Clamp points to expected ranges
if difficulty == "easy":
points = max(20, min(40, points))
elif difficulty == "medium":
points = max(45, min(75, points))
elif difficulty == "hard":
points = max(90, min(150, points))
challenges.append(ChallengeGenerated( if not game_id:
title=ch.get("title", "Unnamed Challenge")[:100], # Try partial match if exact match fails
description=ch.get("description", "Complete the challenge"), for stored_title, gid in title_to_id.items():
type=ch_type, if stored_title in game_title.lower() or game_title.lower() in stored_title:
difficulty=difficulty, game_id = gid
points=points, break
estimated_time=ch.get("estimated_time"),
proof_type=proof_type,
proof_hint=ch.get("proof_hint"),
))
return challenges if not game_id:
print(f"[GPT] Could not match game: '{game_title}'")
continue
challenges = []
for ch in game_data.get("challenges", []):
challenges.append(self._parse_challenge(ch))
result[game_id] = challenges
print(f"[GPT] Generated {len(challenges)} challenges for '{id_to_title.get(game_id)}'")
return result
def _parse_challenge(self, ch: dict) -> ChallengeGenerated:
"""Parse and validate a single challenge from GPT response"""
ch_type = ch.get("type", "completion")
if ch_type not in ["completion", "no_death", "speedrun", "collection", "achievement", "challenge_run"]:
ch_type = "completion"
difficulty = ch.get("difficulty", "medium")
if difficulty not in ["easy", "medium", "hard"]:
difficulty = "medium"
proof_type = ch.get("proof_type", "screenshot")
if proof_type not in ["screenshot", "video", "steam"]:
proof_type = "screenshot"
points = ch.get("points", 30)
if not isinstance(points, int) or points < 1:
points = 30
if difficulty == "easy":
points = max(20, min(40, points))
elif difficulty == "medium":
points = max(45, min(75, points))
elif difficulty == "hard":
points = max(90, min(150, points))
return ChallengeGenerated(
title=ch.get("title", "Unnamed Challenge")[:100],
description=ch.get("description", "Complete the challenge"),
type=ch_type,
difficulty=difficulty,
points=points,
estimated_time=ch.get("estimated_time"),
proof_type=proof_type,
proof_hint=ch.get("proof_hint"),
)
gpt_service = GPTService()

View File

@@ -22,7 +22,8 @@ class TelegramNotifier:
self, self,
chat_id: int, chat_id: int,
text: str, text: str,
parse_mode: str = "HTML" parse_mode: str = "HTML",
reply_markup: dict | None = None
) -> bool: ) -> bool:
"""Send a message to a Telegram chat.""" """Send a message to a Telegram chat."""
if not self.bot_token: if not self.bot_token:
@@ -31,13 +32,17 @@ class TelegramNotifier:
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
payload = {
"chat_id": chat_id,
"text": text,
"parse_mode": parse_mode
}
if reply_markup:
payload["reply_markup"] = reply_markup
response = await client.post( response = await client.post(
f"{self.api_url}/sendMessage", f"{self.api_url}/sendMessage",
json={ json=payload,
"chat_id": chat_id,
"text": text,
"parse_mode": parse_mode
},
timeout=10.0 timeout=10.0
) )
if response.status_code == 200: if response.status_code == 200:
@@ -53,7 +58,8 @@ class TelegramNotifier:
self, self,
db: AsyncSession, db: AsyncSession,
user_id: int, user_id: int,
message: str message: str,
reply_markup: dict | None = None
) -> bool: ) -> bool:
"""Send notification to a user by user_id.""" """Send notification to a user by user_id."""
result = await db.execute( result = await db.execute(
@@ -61,10 +67,16 @@ class TelegramNotifier:
) )
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user or not user.telegram_id: if not user:
logger.warning(f"[Notify] User {user_id} not found")
return False return False
return await self.send_message(user.telegram_id, message) if not user.telegram_id:
logger.warning(f"[Notify] User {user_id} ({user.nickname}) has no telegram_id")
return False
logger.info(f"[Notify] Sending to user {user.nickname} (telegram_id={user.telegram_id})")
return await self.send_message(user.telegram_id, message, reply_markup=reply_markup)
async def notify_marathon_participants( async def notify_marathon_participants(
self, self,
@@ -171,16 +183,41 @@ class TelegramNotifier:
db: AsyncSession, db: AsyncSession,
user_id: int, user_id: int,
marathon_title: str, marathon_title: str,
challenge_title: str challenge_title: str,
assignment_id: int
) -> bool: ) -> bool:
"""Notify user about dispute raised on their assignment.""" """Notify user about dispute raised on their assignment."""
message = ( logger.info(f"[Dispute] Sending notification to user_id={user_id} for assignment_id={assignment_id}")
f"⚠️ <b>На твоё задание подан спор</b>\n\n"
f"Марафон: {marathon_title}\n" dispute_url = f"{settings.FRONTEND_URL}/assignments/{assignment_id}"
f"Задание: {challenge_title}\n\n" logger.info(f"[Dispute] URL: {dispute_url}")
f"Зайди на сайт, чтобы ответить на спор."
) # Telegram requires HTTPS for inline keyboard URLs
return await self.notify_user(db, user_id, message) use_inline_button = dispute_url.startswith("https://")
if use_inline_button:
message = (
f"⚠️ <b>На твоё задание подан спор</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Задание: {challenge_title}"
)
reply_markup = {
"inline_keyboard": [[
{"text": "Открыть спор", "url": dispute_url}
]]
}
else:
message = (
f"⚠️ <b>На твоё задание подан спор</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Задание: {challenge_title}\n\n"
f"🔗 {dispute_url}"
)
reply_markup = None
result = await self.notify_user(db, user_id, message, reply_markup=reply_markup)
logger.info(f"[Dispute] Notification result: {result}")
return result
async def notify_dispute_resolved( async def notify_dispute_resolved(
self, self,

View File

@@ -19,7 +19,7 @@ pydantic-settings==2.1.0
email-validator==2.1.0 email-validator==2.1.0
# OpenAI # OpenAI
openai==1.12.0 openai==2.12.0
# Telegram notifications # Telegram notifications
httpx==0.26.0 httpx==0.26.0

View File

@@ -17,6 +17,9 @@ import { PlayPage } from '@/pages/PlayPage'
import { LeaderboardPage } from '@/pages/LeaderboardPage' import { LeaderboardPage } from '@/pages/LeaderboardPage'
import { InvitePage } from '@/pages/InvitePage' import { InvitePage } from '@/pages/InvitePage'
import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage' import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage'
import { ProfilePage } from '@/pages/ProfilePage'
import { UserProfilePage } from '@/pages/UserProfilePage'
import { NotFoundPage } from '@/pages/NotFoundPage'
// Protected route wrapper // Protected route wrapper
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -132,6 +135,21 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
{/* Profile routes */}
<Route
path="profile"
element={
<ProtectedRoute>
<ProfilePage />
</ProtectedRoute>
}
/>
<Route path="users/:id" element={<UserProfilePage />} />
{/* 404 - must be last */}
<Route path="*" element={<NotFoundPage />} />
</Route> </Route>
</Routes> </Routes>
</> </>

View File

@@ -7,3 +7,5 @@ export { adminApi } from './admin'
export { eventsApi } from './events' export { eventsApi } from './events'
export { challengesApi } from './challenges' export { challengesApi } from './challenges'
export { assignmentsApi } from './assignments' export { assignmentsApi } from './assignments'
export { usersApi } from './users'
export { telegramApi } from './telegram'

42
frontend/src/api/users.ts Normal file
View File

@@ -0,0 +1,42 @@
import client from './client'
import type { User, UserProfilePublic, UserStats, PasswordChangeData } from '@/types'
export interface UpdateNicknameData {
nickname: string
}
export const usersApi = {
// Получить публичный профиль пользователя со статистикой
getProfile: async (userId: number): Promise<UserProfilePublic> => {
const response = await client.get<UserProfilePublic>(`/users/${userId}/profile`)
return response.data
},
// Получить свою статистику
getMyStats: async (): Promise<UserStats> => {
const response = await client.get<UserStats>('/users/me/stats')
return response.data
},
// Обновить никнейм
updateNickname: async (data: UpdateNicknameData): Promise<User> => {
const response = await client.patch<User>('/users/me', data)
return response.data
},
// Загрузить аватар
uploadAvatar: async (file: File): Promise<User> => {
const formData = new FormData()
formData.append('file', file)
const response = await client.post<User>('/users/me/avatar', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
},
// Сменить пароль
changePassword: async (data: PasswordChangeData): Promise<{ message: string }> => {
const response = await client.post<{ message: string }>('/users/me/password', data)
return response.data
},
}

View File

@@ -34,10 +34,13 @@ export function Layout() {
</Link> </Link>
<div className="flex items-center gap-3 ml-4 pl-4 border-l border-gray-700"> <div className="flex items-center gap-3 ml-4 pl-4 border-l border-gray-700">
<div className="flex items-center gap-2 text-gray-300"> <Link
to="/profile"
className="flex items-center gap-2 text-gray-300 hover:text-white transition-colors"
>
<User className="w-5 h-5" /> <User className="w-5 h-5" />
<span>{user?.nickname}</span> <span>{user?.nickname}</span>
</div> </Link>
<TelegramLink /> <TelegramLink />

View File

@@ -0,0 +1,33 @@
import { Link } from 'react-router-dom'
import { Button } from '@/components/ui'
import { Gamepad2, Home, Ghost } from 'lucide-react'
export function NotFoundPage() {
return (
<div className="min-h-[60vh] flex flex-col items-center justify-center text-center px-4">
{/* Иконка с анимацией */}
<div className="relative mb-8">
<Ghost className="w-32 h-32 text-gray-700 animate-bounce" />
<Gamepad2 className="w-12 h-12 text-primary-500 absolute -bottom-2 -right-2" />
</div>
{/* Заголовок */}
<h1 className="text-7xl font-bold text-white mb-4">404</h1>
<h2 className="text-2xl font-semibold text-gray-400 mb-2">
Страница не найдена
</h2>
<p className="text-gray-500 mb-8 max-w-md">
Похоже, эта страница ушла на марафон и не вернулась.
Попробуй начать с главной.
</p>
{/* Кнопка */}
<Link to="/">
<Button size="lg" className="flex items-center gap-2">
<Home className="w-5 h-5" />
На главную
</Button>
</Link>
</div>
)
}

View File

@@ -0,0 +1,461 @@
import { useState, useEffect, useRef } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useAuthStore } from '@/store/auth'
import { usersApi, telegramApi, authApi } from '@/api'
import type { UserStats } from '@/types'
import { useToast } from '@/store/toast'
import {
Button, Input, Card, CardHeader, CardTitle, CardContent
} from '@/components/ui'
import {
User, Camera, Trophy, Target, CheckCircle, Flame,
Loader2, MessageCircle, Link2, Link2Off, ExternalLink,
Eye, EyeOff, Save, KeyRound
} from 'lucide-react'
// Схемы валидации
const nicknameSchema = z.object({
nickname: z.string().min(2, 'Минимум 2 символа').max(50, 'Максимум 50 символов'),
})
const passwordSchema = z.object({
current_password: z.string().min(6, 'Минимум 6 символов'),
new_password: z.string().min(6, 'Минимум 6 символов').max(100, 'Максимум 100 символов'),
confirm_password: z.string(),
}).refine((data) => data.new_password === data.confirm_password, {
message: 'Пароли не совпадают',
path: ['confirm_password'],
})
type NicknameForm = z.infer<typeof nicknameSchema>
type PasswordForm = z.infer<typeof passwordSchema>
export function ProfilePage() {
const { user, updateUser } = useAuthStore()
const toast = useToast()
// Состояние
const [stats, setStats] = useState<UserStats | null>(null)
const [isLoadingStats, setIsLoadingStats] = useState(true)
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
const [showPasswordForm, setShowPasswordForm] = useState(false)
const [showCurrentPassword, setShowCurrentPassword] = useState(false)
const [showNewPassword, setShowNewPassword] = useState(false)
// Telegram state
const [telegramLoading, setTelegramLoading] = useState(false)
const [isPolling, setIsPolling] = useState(false)
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
// Формы
const nicknameForm = useForm<NicknameForm>({
resolver: zodResolver(nicknameSchema),
defaultValues: { nickname: user?.nickname || '' },
})
const passwordForm = useForm<PasswordForm>({
resolver: zodResolver(passwordSchema),
defaultValues: { current_password: '', new_password: '', confirm_password: '' },
})
// Загрузка статистики
useEffect(() => {
loadStats()
return () => {
if (pollingRef.current) clearInterval(pollingRef.current)
}
}, [])
// Обновляем форму никнейма при изменении user
useEffect(() => {
if (user?.nickname) {
nicknameForm.reset({ nickname: user.nickname })
}
}, [user?.nickname])
const loadStats = async () => {
try {
const data = await usersApi.getMyStats()
setStats(data)
} catch (error) {
console.error('Failed to load stats:', error)
} finally {
setIsLoadingStats(false)
}
}
// Обновление никнейма
const onNicknameSubmit = async (data: NicknameForm) => {
try {
const updatedUser = await usersApi.updateNickname(data)
updateUser({ nickname: updatedUser.nickname })
toast.success('Никнейм обновлен')
} catch {
toast.error('Не удалось обновить никнейм')
}
}
// Загрузка аватара
const handleAvatarClick = () => {
fileInputRef.current?.click()
}
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Валидация
if (!file.type.startsWith('image/')) {
toast.error('Файл должен быть изображением')
return
}
if (file.size > 5 * 1024 * 1024) {
toast.error('Максимальный размер файла 5 МБ')
return
}
setIsUploadingAvatar(true)
try {
const updatedUser = await usersApi.uploadAvatar(file)
updateUser({ avatar_url: updatedUser.avatar_url })
toast.success('Аватар обновлен')
} catch {
toast.error('Не удалось загрузить аватар')
} finally {
setIsUploadingAvatar(false)
}
}
// Смена пароля
const onPasswordSubmit = async (data: PasswordForm) => {
try {
await usersApi.changePassword({
current_password: data.current_password,
new_password: data.new_password,
})
toast.success('Пароль успешно изменен')
passwordForm.reset()
setShowPasswordForm(false)
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
const message = err.response?.data?.detail || 'Не удалось сменить пароль'
toast.error(message)
}
}
// Telegram функции
const startPolling = () => {
setIsPolling(true)
let attempts = 0
pollingRef.current = setInterval(async () => {
attempts++
try {
const userData = await authApi.me()
if (userData.telegram_id) {
updateUser({
telegram_id: userData.telegram_id,
telegram_username: userData.telegram_username,
telegram_first_name: userData.telegram_first_name,
telegram_last_name: userData.telegram_last_name,
telegram_avatar_url: userData.telegram_avatar_url,
})
toast.success('Telegram привязан!')
setIsPolling(false)
if (pollingRef.current) clearInterval(pollingRef.current)
}
} catch { /* ignore */ }
if (attempts >= 60) {
setIsPolling(false)
if (pollingRef.current) clearInterval(pollingRef.current)
}
}, 5000)
}
const handleLinkTelegram = async () => {
setTelegramLoading(true)
try {
const { bot_url } = await telegramApi.generateLinkToken()
window.open(bot_url, '_blank')
startPolling()
} catch {
toast.error('Не удалось сгенерировать ссылку')
} finally {
setTelegramLoading(false)
}
}
const handleUnlinkTelegram = async () => {
setTelegramLoading(true)
try {
await telegramApi.unlinkTelegram()
updateUser({
telegram_id: null,
telegram_username: null,
telegram_first_name: null,
telegram_last_name: null,
telegram_avatar_url: null,
})
toast.success('Telegram отвязан')
} catch {
toast.error('Не удалось отвязать Telegram')
} finally {
setTelegramLoading(false)
}
}
const isLinked = !!user?.telegram_id
const displayAvatar = user?.telegram_avatar_url || user?.avatar_url
return (
<div className="max-w-2xl mx-auto space-y-6">
<h1 className="text-2xl font-bold text-white">Мой профиль</h1>
{/* Карточка профиля */}
<Card>
<CardContent className="pt-6">
<div className="flex items-start gap-6">
{/* Аватар */}
<div className="relative group flex-shrink-0">
<button
onClick={handleAvatarClick}
disabled={isUploadingAvatar}
className="relative w-24 h-24 rounded-full overflow-hidden bg-gray-700 hover:opacity-80 transition-opacity"
>
{displayAvatar ? (
<img
src={displayAvatar}
alt={user?.nickname}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<User className="w-12 h-12 text-gray-500" />
</div>
)}
<div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
{isUploadingAvatar ? (
<Loader2 className="w-6 h-6 text-white animate-spin" />
) : (
<Camera className="w-6 h-6 text-white" />
)}
</div>
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleAvatarChange}
className="hidden"
/>
</div>
{/* Форма никнейма */}
<div className="flex-1">
<form onSubmit={nicknameForm.handleSubmit(onNicknameSubmit)} className="space-y-4">
<Input
label="Никнейм"
{...nicknameForm.register('nickname')}
error={nicknameForm.formState.errors.nickname?.message}
/>
<Button
type="submit"
size="sm"
isLoading={nicknameForm.formState.isSubmitting}
disabled={!nicknameForm.formState.isDirty}
>
<Save className="w-4 h-4 mr-2" />
Сохранить
</Button>
</form>
</div>
</div>
</CardContent>
</Card>
{/* Статистика */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="w-5 h-5 text-yellow-500" />
Статистика
</CardTitle>
</CardHeader>
<CardContent>
{isLoadingStats ? (
<div className="flex justify-center py-4">
<Loader2 className="w-6 h-6 animate-spin text-primary-500" />
</div>
) : stats ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-900 rounded-lg p-4 text-center">
<Target className="w-6 h-6 text-blue-400 mx-auto mb-2" />
<div className="text-2xl font-bold text-white">{stats.marathons_count}</div>
<div className="text-sm text-gray-400">Марафонов</div>
</div>
<div className="bg-gray-900 rounded-lg p-4 text-center">
<Trophy className="w-6 h-6 text-yellow-500 mx-auto mb-2" />
<div className="text-2xl font-bold text-white">{stats.wins_count}</div>
<div className="text-sm text-gray-400">Побед</div>
</div>
<div className="bg-gray-900 rounded-lg p-4 text-center">
<CheckCircle className="w-6 h-6 text-green-500 mx-auto mb-2" />
<div className="text-2xl font-bold text-white">{stats.completed_assignments}</div>
<div className="text-sm text-gray-400">Заданий</div>
</div>
<div className="bg-gray-900 rounded-lg p-4 text-center">
<Flame className="w-6 h-6 text-orange-500 mx-auto mb-2" />
<div className="text-2xl font-bold text-white">{stats.total_points_earned}</div>
<div className="text-sm text-gray-400">Очков</div>
</div>
</div>
) : (
<p className="text-gray-400 text-center">Не удалось загрузить статистику</p>
)}
</CardContent>
</Card>
{/* Telegram */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageCircle className="w-5 h-5 text-blue-400" />
Telegram
</CardTitle>
</CardHeader>
<CardContent>
{isLinked ? (
<div className="space-y-4">
<div className="flex items-center gap-4 p-4 bg-gray-900 rounded-lg">
<div className="w-12 h-12 rounded-full bg-blue-500/20 flex items-center justify-center overflow-hidden">
{user?.telegram_avatar_url ? (
<img
src={user.telegram_avatar_url}
alt="Telegram avatar"
className="w-full h-full object-cover"
/>
) : (
<Link2 className="w-6 h-6 text-blue-400" />
)}
</div>
<div className="flex-1">
<p className="text-white font-medium">
{user?.telegram_first_name} {user?.telegram_last_name}
</p>
{user?.telegram_username && (
<p className="text-blue-400 text-sm">@{user.telegram_username}</p>
)}
</div>
<Button
variant="danger"
size="sm"
onClick={handleUnlinkTelegram}
isLoading={telegramLoading}
>
<Link2Off className="w-4 h-4 mr-2" />
Отвязать
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<p className="text-gray-400">
Привяжи Telegram для получения уведомлений о событиях и марафонах.
</p>
{isPolling ? (
<div className="p-4 bg-blue-500/20 border border-blue-500/50 rounded-lg">
<div className="flex items-center gap-3">
<Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
<p className="text-blue-400">Ожидание привязки...</p>
</div>
</div>
) : (
<Button onClick={handleLinkTelegram} isLoading={telegramLoading}>
<ExternalLink className="w-4 h-4 mr-2" />
Привязать Telegram
</Button>
)}
</div>
)}
</CardContent>
</Card>
{/* Смена пароля */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="w-5 h-5 text-gray-400" />
Безопасность
</CardTitle>
</CardHeader>
<CardContent>
{!showPasswordForm ? (
<Button variant="secondary" onClick={() => setShowPasswordForm(true)}>
Сменить пароль
</Button>
) : (
<form onSubmit={passwordForm.handleSubmit(onPasswordSubmit)} className="space-y-4">
<div className="relative">
<Input
label="Текущий пароль"
type={showCurrentPassword ? 'text' : 'password'}
{...passwordForm.register('current_password')}
error={passwordForm.formState.errors.current_password?.message}
/>
<button
type="button"
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
className="absolute right-3 top-8 text-gray-400 hover:text-white"
>
{showCurrentPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
<div className="relative">
<Input
label="Новый пароль"
type={showNewPassword ? 'text' : 'password'}
{...passwordForm.register('new_password')}
error={passwordForm.formState.errors.new_password?.message}
/>
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
className="absolute right-3 top-8 text-gray-400 hover:text-white"
>
{showNewPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
<Input
label="Подтвердите новый пароль"
type={showNewPassword ? 'text' : 'password'}
{...passwordForm.register('confirm_password')}
error={passwordForm.formState.errors.confirm_password?.message}
/>
<div className="flex gap-2">
<Button type="submit" isLoading={passwordForm.formState.isSubmitting}>
Сменить пароль
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
setShowPasswordForm(false)
passwordForm.reset()
}}
>
Отмена
</Button>
</div>
</form>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,174 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { useAuthStore } from '@/store/auth'
import { usersApi } from '@/api'
import type { UserProfilePublic } from '@/types'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui'
import {
User, Trophy, Target, CheckCircle, Flame,
Loader2, ArrowLeft, Calendar
} from 'lucide-react'
export function UserProfilePage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const currentUser = useAuthStore((state) => state.user)
const [profile, setProfile] = useState<UserProfilePublic | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!id) return
const userId = parseInt(id)
// Редирект на свой профиль
if (currentUser && userId === currentUser.id) {
navigate('/profile', { replace: true })
return
}
loadProfile(userId)
}, [id, currentUser, navigate])
const loadProfile = async (userId: number) => {
setIsLoading(true)
setError(null)
try {
const data = await usersApi.getProfile(userId)
setProfile(data)
} catch (err: unknown) {
const error = err as { response?: { status?: number } }
if (error.response?.status === 404) {
setError('Пользователь не найден')
} else {
setError('Не удалось загрузить профиль')
}
} finally {
setIsLoading(false)
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric',
})
}
if (isLoading) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary-500" />
</div>
)
}
if (error || !profile) {
return (
<div className="max-w-2xl mx-auto">
<Card>
<CardContent className="py-12 text-center">
<User className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<h2 className="text-xl font-bold text-white mb-2">
{error || 'Пользователь не найден'}
</h2>
<Link to="/" className="text-primary-400 hover:text-primary-300">
Вернуться на главную
</Link>
</CardContent>
</Card>
</div>
)
}
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Кнопка назад */}
<button
onClick={() => navigate(-1)}
className="flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
>
<ArrowLeft className="w-5 h-5" />
Назад
</button>
{/* Профиль */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center gap-6">
{/* Аватар */}
<div className="w-24 h-24 rounded-full overflow-hidden bg-gray-700 flex-shrink-0">
{profile.avatar_url ? (
<img
src={profile.avatar_url}
alt={profile.nickname}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<User className="w-12 h-12 text-gray-500" />
</div>
)}
</div>
{/* Инфо */}
<div>
<h1 className="text-2xl font-bold text-white mb-2">
{profile.nickname}
</h1>
<div className="flex items-center gap-2 text-gray-400 text-sm">
<Calendar className="w-4 h-4" />
<span>Зарегистрирован {formatDate(profile.created_at)}</span>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Статистика */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="w-5 h-5 text-yellow-500" />
Статистика
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-900 rounded-lg p-4 text-center">
<Target className="w-6 h-6 text-blue-400 mx-auto mb-2" />
<div className="text-2xl font-bold text-white">
{profile.stats.marathons_count}
</div>
<div className="text-sm text-gray-400">Марафонов</div>
</div>
<div className="bg-gray-900 rounded-lg p-4 text-center">
<Trophy className="w-6 h-6 text-yellow-500 mx-auto mb-2" />
<div className="text-2xl font-bold text-white">
{profile.stats.wins_count}
</div>
<div className="text-sm text-gray-400">Побед</div>
</div>
<div className="bg-gray-900 rounded-lg p-4 text-center">
<CheckCircle className="w-6 h-6 text-green-500 mx-auto mb-2" />
<div className="text-2xl font-bold text-white">
{profile.stats.completed_assignments}
</div>
<div className="text-sm text-gray-400">Заданий</div>
</div>
<div className="bg-gray-900 rounded-lg p-4 text-center">
<Flame className="w-6 h-6 text-orange-500 mx-auto mb-2" />
<div className="text-2xl font-bold text-white">
{profile.stats.total_points_earned}
</div>
<div className="text-sm text-gray-400">Очков</div>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -7,3 +7,6 @@ export { MarathonPage } from './MarathonPage'
export { LobbyPage } from './LobbyPage' export { LobbyPage } from './LobbyPage'
export { PlayPage } from './PlayPage' export { PlayPage } from './PlayPage'
export { LeaderboardPage } from './LeaderboardPage' export { LeaderboardPage } from './LeaderboardPage'
export { ProfilePage } from './ProfilePage'
export { UserProfilePage } from './UserProfilePage'
export { NotFoundPage } from './NotFoundPage'

View File

@@ -464,3 +464,24 @@ export interface ReturnedAssignment {
original_completed_at: string original_completed_at: string
dispute_reason: string dispute_reason: string
} }
// Profile types
export interface UserStats {
marathons_count: number
wins_count: number
completed_assignments: number
total_points_earned: number
}
export interface UserProfilePublic {
id: number
nickname: string
avatar_url: string | null
created_at: string
stats: UserStats
}
export interface PasswordChangeData {
current_password: string
new_password: string
}