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,
user_id=assignment.participant.user_id,
marathon_title=marathon.title,
challenge_title=assignment.challenge.title
challenge_title=assignment.challenge.title,
assignment_id=assignment_id
)
# Load relationships for response

View File

@@ -14,12 +14,10 @@ from app.schemas import (
ChallengesPreviewResponse,
ChallengesSaveRequest,
)
from app.services.gpt import GPTService
from app.services.gpt import gpt_service
router = APIRouter(tags=["challenges"])
gpt_service = GPTService()
async def get_challenge_or_404(db, challenge_id: int) -> Challenge:
result = await db.execute(
@@ -215,22 +213,35 @@ async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: Db
if not games:
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:
# Check if game already has challenges
existing = await db.scalar(
select(Challenge.id).where(Challenge.game_id == game.id).limit(1)
)
if existing:
continue # Skip if already has challenges
if not existing:
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:
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:
preview_challenges.append(ChallengePreview(
game_id=game.id,
game_title=game.title,
game_id=game_id,
game_title=game_title,
title=ch_data.title,
description=ch_data.description,
type=ch_data.type,
@@ -242,8 +253,7 @@ async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: Db
))
except Exception as e:
# Log error but continue with other games
print(f"Error generating challenges for {game.title}: {e}")
print(f"Error generating challenges: {e}")
return ChallengesPreviewResponse(challenges=preview_challenges)

View File

@@ -1,10 +1,16 @@
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.core.config import settings
from app.models import User
from app.schemas import UserPublic, UserUpdate, TelegramLink, MessageResponse
from app.core.security import verify_password, get_password_hash
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
router = APIRouter(prefix="/users", tags=["users"])
@@ -125,3 +131,142 @@ async def unlink_telegram(
await db.commit()
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_LINK_TOKEN_EXPIRE_MINUTES: int = 10
# Frontend
FRONTEND_URL: str = "http://localhost:3000"
# Uploads
UPLOAD_DIR: str = "uploads"
MAX_IMAGE_SIZE: int = 15 * 1024 * 1024 # 15 MB

View File

@@ -1,6 +1,13 @@
import logging
from contextlib import asynccontextmanager
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.staticfiles import StaticFiles
from pathlib import Path

View File

@@ -35,4 +35,4 @@ class Assignment(Base):
participant: Mapped["Participant"] = relationship("Participant", back_populates="assignments")
challenge: Mapped["Challenge"] = relationship("Challenge", 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,
TokenResponse,
TelegramLink,
PasswordChange,
UserStats,
UserProfilePublic,
)
from app.schemas.marathon import (
MarathonCreate,
@@ -87,6 +90,9 @@ __all__ = [
"UserWithTelegram",
"TokenResponse",
"TelegramLink",
"PasswordChange",
"UserStats",
"UserProfilePublic",
# Marathon
"MarathonCreate",
"MarathonUpdate",

View File

@@ -58,3 +58,28 @@ class TokenResponse(BaseModel):
class TelegramLink(BaseModel):
telegram_id: int
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,85 +13,114 @@ class GPTService:
async def generate_challenges(
self,
game_title: str,
game_genre: str | None = None
) -> list[ChallengeGenerated]:
games: list[dict]
) -> dict[int, list[ChallengeGenerated]]:
"""
Generate challenges for a game using GPT.
Generate challenges for multiple games in one API call.
Args:
game_title: Name of the game
game_genre: Optional genre of the game
games: List of dicts with keys: id, title, genre
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 врагов"
Примеры ХОРОШИХ челленджей:
- Dark Souls: "Победи Орнштейна и Смоуга без призыва" / "Пройди Чумной город без отравления"
- GTA V: "Получи золото в миссии «Ювелирное дело»" / "Выиграй уличную гонку на Vinewood"
- Hollow Knight: "Победи Хорнет без получения урона" / "Найди все грибные споры в Грибных пустошах"
- Minecraft: "Убей Дракона Края за один визит в Энд" / "Построй работающую ферму железа"
Требования по сложности ДЛЯ КАЖДОЙ ИГРЫ:
- 2 лёгких (15-30 мин): простые задачи
- 2 средних (1-2 часа): требуют навыка
- 2 сложных (3+ часа): серьёзный челлендж
Требования по сложности:
- 2 лёгких (15-30 мин): простые задачи, знакомство с игрой
- 2 средних (1-2 часа): требуют навыка или исследования
- 2 сложных (3+ часа): серьёзный челлендж, достижения, полное прохождение
Формат ответа — JSON с объектом где ключи это ТОЧНЫЕ названия игр, как они указаны в запросе:
{{
"Название игры 1": {{
"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:
- title: название на русском (до 50 символов), конкретное и понятное
- 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": "..."}}]}}"""
points: easy=20-40, medium=45-75, hard=90-150
Ответь ТОЛЬКО JSON."""
response = await self.client.chat.completions.create(
model="gpt-5-mini",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
temperature=0.8,
max_tokens=2500,
)
content = response.choices[0].message.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 = []
for ch in data.get("challenges", []):
# Validate and normalize type
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"
# Validate difficulty
difficulty = ch.get("difficulty", "medium")
if difficulty not in ["easy", "medium", "hard"]:
difficulty = "medium"
# Validate proof_type
proof_type = ch.get("proof_type", "screenshot")
if proof_type not in ["screenshot", "video", "steam"]:
proof_type = "screenshot"
# Validate points based on difficulty
points = ch.get("points", 30)
if not isinstance(points, int) or points < 1:
points = 30
# Clamp points to expected ranges
if difficulty == "easy":
points = max(20, min(40, points))
elif difficulty == "medium":
@@ -99,7 +128,7 @@ class GPTService:
elif difficulty == "hard":
points = max(90, min(150, points))
challenges.append(ChallengeGenerated(
return ChallengeGenerated(
title=ch.get("title", "Unnamed Challenge")[:100],
description=ch.get("description", "Complete the challenge"),
type=ch_type,
@@ -108,6 +137,7 @@ class GPTService:
estimated_time=ch.get("estimated_time"),
proof_type=proof_type,
proof_hint=ch.get("proof_hint"),
))
)
return challenges
gpt_service = GPTService()

View File

@@ -22,7 +22,8 @@ class TelegramNotifier:
self,
chat_id: int,
text: str,
parse_mode: str = "HTML"
parse_mode: str = "HTML",
reply_markup: dict | None = None
) -> bool:
"""Send a message to a Telegram chat."""
if not self.bot_token:
@@ -31,13 +32,17 @@ class TelegramNotifier:
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.api_url}/sendMessage",
json={
payload = {
"chat_id": chat_id,
"text": text,
"parse_mode": parse_mode
},
}
if reply_markup:
payload["reply_markup"] = reply_markup
response = await client.post(
f"{self.api_url}/sendMessage",
json=payload,
timeout=10.0
)
if response.status_code == 200:
@@ -53,7 +58,8 @@ class TelegramNotifier:
self,
db: AsyncSession,
user_id: int,
message: str
message: str,
reply_markup: dict | None = None
) -> bool:
"""Send notification to a user by user_id."""
result = await db.execute(
@@ -61,10 +67,16 @@ class TelegramNotifier:
)
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 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(
self,
@@ -171,16 +183,41 @@ class TelegramNotifier:
db: AsyncSession,
user_id: int,
marathon_title: str,
challenge_title: str
challenge_title: str,
assignment_id: int
) -> bool:
"""Notify user about dispute raised on their assignment."""
logger.info(f"[Dispute] Sending notification to user_id={user_id} for assignment_id={assignment_id}")
dispute_url = f"{settings.FRONTEND_URL}/assignments/{assignment_id}"
logger.info(f"[Dispute] URL: {dispute_url}")
# Telegram requires HTTPS for inline keyboard URLs
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"Зайди на сайт, чтобы ответить на спор."
f"🔗 {dispute_url}"
)
return await self.notify_user(db, user_id, message)
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(
self,

View File

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

View File

@@ -17,6 +17,9 @@ import { PlayPage } from '@/pages/PlayPage'
import { LeaderboardPage } from '@/pages/LeaderboardPage'
import { InvitePage } from '@/pages/InvitePage'
import { AssignmentDetailPage } from '@/pages/AssignmentDetailPage'
import { ProfilePage } from '@/pages/ProfilePage'
import { UserProfilePage } from '@/pages/UserProfilePage'
import { NotFoundPage } from '@/pages/NotFoundPage'
// Protected route wrapper
function ProtectedRoute({ children }: { children: React.ReactNode }) {
@@ -132,6 +135,21 @@ function App() {
</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>
</Routes>
</>

View File

@@ -7,3 +7,5 @@ export { adminApi } from './admin'
export { eventsApi } from './events'
export { challengesApi } from './challenges'
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>
<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" />
<span>{user?.nickname}</span>
</div>
</Link>
<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 { PlayPage } from './PlayPage'
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
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
}