Update GPT and add Profile
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
if not games_to_generate:
|
||||||
|
return ChallengesPreviewResponse(challenges=[])
|
||||||
|
|
||||||
|
# Generate challenges for all games in one API call
|
||||||
|
preview_challenges = []
|
||||||
try:
|
try:
|
||||||
challenges_data = await gpt_service.generate_challenges(game.title, game.genre)
|
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,
|
||||||
@@ -242,8 +253,7 @@ async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: Db
|
|||||||
))
|
))
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -13,85 +13,114 @@ 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)
|
||||||
|
|
||||||
|
# Map game titles to IDs (case-insensitive, strip whitespace)
|
||||||
|
title_to_id = {g['title'].lower().strip(): g['id'] for g in games}
|
||||||
|
|
||||||
|
# Also keep original titles for logging
|
||||||
|
id_to_title = {g['id']: g['title'] for g in games}
|
||||||
|
|
||||||
|
print(f"[GPT] Requested games: {[g['title'] for g in games]}")
|
||||||
|
print(f"[GPT] Response keys: {list(data.keys())}")
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for game_title, game_data in data.items():
|
||||||
|
# Try exact match first, then case-insensitive
|
||||||
|
game_id = title_to_id.get(game_title.lower().strip())
|
||||||
|
|
||||||
|
if not game_id:
|
||||||
|
# Try partial match if exact match fails
|
||||||
|
for stored_title, gid in title_to_id.items():
|
||||||
|
if stored_title in game_title.lower() or game_title.lower() in stored_title:
|
||||||
|
game_id = gid
|
||||||
|
break
|
||||||
|
|
||||||
|
if not game_id:
|
||||||
|
print(f"[GPT] Could not match game: '{game_title}'")
|
||||||
|
continue
|
||||||
|
|
||||||
challenges = []
|
challenges = []
|
||||||
for ch in data.get("challenges", []):
|
for ch in game_data.get("challenges", []):
|
||||||
# Validate and normalize type
|
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")
|
ch_type = ch.get("type", "completion")
|
||||||
if ch_type not in ["completion", "no_death", "speedrun", "collection", "achievement", "challenge_run"]:
|
if ch_type not in ["completion", "no_death", "speedrun", "collection", "achievement", "challenge_run"]:
|
||||||
ch_type = "completion"
|
ch_type = "completion"
|
||||||
|
|
||||||
# Validate difficulty
|
|
||||||
difficulty = ch.get("difficulty", "medium")
|
difficulty = ch.get("difficulty", "medium")
|
||||||
if difficulty not in ["easy", "medium", "hard"]:
|
if difficulty not in ["easy", "medium", "hard"]:
|
||||||
difficulty = "medium"
|
difficulty = "medium"
|
||||||
|
|
||||||
# Validate proof_type
|
|
||||||
proof_type = ch.get("proof_type", "screenshot")
|
proof_type = ch.get("proof_type", "screenshot")
|
||||||
if proof_type not in ["screenshot", "video", "steam"]:
|
if proof_type not in ["screenshot", "video", "steam"]:
|
||||||
proof_type = "screenshot"
|
proof_type = "screenshot"
|
||||||
|
|
||||||
# Validate points based on difficulty
|
|
||||||
points = ch.get("points", 30)
|
points = ch.get("points", 30)
|
||||||
if not isinstance(points, int) or points < 1:
|
if not isinstance(points, int) or points < 1:
|
||||||
points = 30
|
points = 30
|
||||||
# Clamp points to expected ranges
|
|
||||||
if difficulty == "easy":
|
if difficulty == "easy":
|
||||||
points = max(20, min(40, points))
|
points = max(20, min(40, points))
|
||||||
elif difficulty == "medium":
|
elif difficulty == "medium":
|
||||||
@@ -99,7 +128,7 @@ class GPTService:
|
|||||||
elif difficulty == "hard":
|
elif difficulty == "hard":
|
||||||
points = max(90, min(150, points))
|
points = max(90, min(150, points))
|
||||||
|
|
||||||
challenges.append(ChallengeGenerated(
|
return ChallengeGenerated(
|
||||||
title=ch.get("title", "Unnamed Challenge")[:100],
|
title=ch.get("title", "Unnamed Challenge")[:100],
|
||||||
description=ch.get("description", "Complete the challenge"),
|
description=ch.get("description", "Complete the challenge"),
|
||||||
type=ch_type,
|
type=ch_type,
|
||||||
@@ -108,6 +137,7 @@ class GPTService:
|
|||||||
estimated_time=ch.get("estimated_time"),
|
estimated_time=ch.get("estimated_time"),
|
||||||
proof_type=proof_type,
|
proof_type=proof_type,
|
||||||
proof_hint=ch.get("proof_hint"),
|
proof_hint=ch.get("proof_hint"),
|
||||||
))
|
)
|
||||||
|
|
||||||
return challenges
|
|
||||||
|
gpt_service = GPTService()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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
42
frontend/src/api/users.ts
Normal 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
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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 />
|
||||||
|
|
||||||
|
|||||||
33
frontend/src/pages/NotFoundPage.tsx
Normal file
33
frontend/src/pages/NotFoundPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
461
frontend/src/pages/ProfilePage.tsx
Normal file
461
frontend/src/pages/ProfilePage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
174
frontend/src/pages/UserProfilePage.tsx
Normal file
174
frontend/src/pages/UserProfilePage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user