diff --git a/backend/app/api/v1/challenges.py b/backend/app/api/v1/challenges.py index 256ae3c..756e50c 100644 --- a/backend/app/api/v1/challenges.py +++ b/backend/app/api/v1/challenges.py @@ -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 - try: - challenges_data = await gpt_service.generate_challenges(game.title, game.genre) + if not games_to_generate: + return ChallengesPreviewResponse(challenges=[]) + # Generate challenges for all games in one API call + preview_challenges = [] + try: + challenges_by_game = await gpt_service.generate_challenges(games_to_generate) + + for game_id, challenges_data in challenges_by_game.items(): + game_title = game_map.get(game_id, "Unknown") for ch_data in challenges_data: 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, @@ -241,9 +252,8 @@ async def preview_challenges(marathon_id: int, current_user: CurrentUser, db: Db proof_hint=ch_data.proof_hint, )) - except Exception as e: - # Log error but continue with other games - print(f"Error generating challenges for {game.title}: {e}") + except Exception as e: + print(f"Error generating challenges: {e}") return ChallengesPreviewResponse(challenges=preview_challenges) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index a263a2f..88b9ffc 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -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, + ) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 4896989..eb4d216 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -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", diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 30ac25a..bf54d33 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -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 diff --git a/backend/app/services/gpt.py b/backend/app/services/gpt.py index 41630c1..dd7bf8a 100644 --- a/backend/app/services/gpt.py +++ b/backend/app/services/gpt.py @@ -13,101 +13,131 @@ 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) - challenges = [] - for ch in data.get("challenges", []): - # Validate and normalize type - ch_type = ch.get("type", "completion") - if ch_type not in ["completion", "no_death", "speedrun", "collection", "achievement", "challenge_run"]: - ch_type = "completion" + # Map game titles to IDs (case-insensitive, strip whitespace) + title_to_id = {g['title'].lower().strip(): g['id'] for g in games} - # Validate difficulty - difficulty = ch.get("difficulty", "medium") - if difficulty not in ["easy", "medium", "hard"]: - difficulty = "medium" + # Also keep original titles for logging + id_to_title = {g['id']: g['title'] for g in games} - # Validate proof_type - proof_type = ch.get("proof_type", "screenshot") - if proof_type not in ["screenshot", "video", "steam"]: - proof_type = "screenshot" + print(f"[GPT] Requested games: {[g['title'] for g in games]}") + print(f"[GPT] Response keys: {list(data.keys())}") - # 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": - points = max(45, min(75, points)) - elif difficulty == "hard": - points = max(90, min(150, points)) + 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()) - challenges.append(ChallengeGenerated( - title=ch.get("title", "Unnamed Challenge")[:100], - description=ch.get("description", "Complete the challenge"), - type=ch_type, - difficulty=difficulty, - points=points, - estimated_time=ch.get("estimated_time"), - proof_type=proof_type, - proof_hint=ch.get("proof_hint"), - )) + 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 - return challenges + if not game_id: + print(f"[GPT] Could not match game: '{game_title}'") + continue + + challenges = [] + for ch in game_data.get("challenges", []): + challenges.append(self._parse_challenge(ch)) + + result[game_id] = challenges + print(f"[GPT] Generated {len(challenges)} challenges for '{id_to_title.get(game_id)}'") + + return result + + def _parse_challenge(self, ch: dict) -> ChallengeGenerated: + """Parse and validate a single challenge from GPT response""" + ch_type = ch.get("type", "completion") + if ch_type not in ["completion", "no_death", "speedrun", "collection", "achievement", "challenge_run"]: + ch_type = "completion" + + difficulty = ch.get("difficulty", "medium") + if difficulty not in ["easy", "medium", "hard"]: + difficulty = "medium" + + proof_type = ch.get("proof_type", "screenshot") + if proof_type not in ["screenshot", "video", "steam"]: + proof_type = "screenshot" + + points = ch.get("points", 30) + if not isinstance(points, int) or points < 1: + points = 30 + if difficulty == "easy": + points = max(20, min(40, points)) + elif difficulty == "medium": + points = max(45, min(75, points)) + elif difficulty == "hard": + points = max(90, min(150, points)) + + return ChallengeGenerated( + title=ch.get("title", "Unnamed Challenge")[:100], + description=ch.get("description", "Complete the challenge"), + type=ch_type, + difficulty=difficulty, + points=points, + estimated_time=ch.get("estimated_time"), + proof_type=proof_type, + proof_hint=ch.get("proof_hint"), + ) + + +gpt_service = GPTService() diff --git a/backend/requirements.txt b/backend/requirements.txt index ecc4760..7bc9357 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b90a703..5ca5940 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> + + {/* Profile routes */} + + + + } + /> + + } /> + + {/* 404 - must be last */} + } /> diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index b76cd5e..67fe8d8 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -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' diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts new file mode 100644 index 0000000..b88e534 --- /dev/null +++ b/frontend/src/api/users.ts @@ -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 => { + const response = await client.get(`/users/${userId}/profile`) + return response.data + }, + + // Получить свою статистику + getMyStats: async (): Promise => { + const response = await client.get('/users/me/stats') + return response.data + }, + + // Обновить никнейм + updateNickname: async (data: UpdateNicknameData): Promise => { + const response = await client.patch('/users/me', data) + return response.data + }, + + // Загрузить аватар + uploadAvatar: async (file: File): Promise => { + const formData = new FormData() + formData.append('file', file) + const response = await client.post('/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 + }, +} diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index 8c49708..d00117b 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -34,10 +34,13 @@ export function Layout() {
-
+ {user?.nickname} -
+ diff --git a/frontend/src/pages/NotFoundPage.tsx b/frontend/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..d650fe8 --- /dev/null +++ b/frontend/src/pages/NotFoundPage.tsx @@ -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 ( +
+ {/* Иконка с анимацией */} +
+ + +
+ + {/* Заголовок */} +

404

+

+ Страница не найдена +

+

+ Похоже, эта страница ушла на марафон и не вернулась. + Попробуй начать с главной. +

+ + {/* Кнопка */} + + + +
+ ) +} diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx new file mode 100644 index 0000000..3e40039 --- /dev/null +++ b/frontend/src/pages/ProfilePage.tsx @@ -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 +type PasswordForm = z.infer + +export function ProfilePage() { + const { user, updateUser } = useAuthStore() + const toast = useToast() + + // Состояние + const [stats, setStats] = useState(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 | null>(null) + + const fileInputRef = useRef(null) + + // Формы + const nicknameForm = useForm({ + resolver: zodResolver(nicknameSchema), + defaultValues: { nickname: user?.nickname || '' }, + }) + + const passwordForm = useForm({ + 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) => { + 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 ( +
+

Мой профиль

+ + {/* Карточка профиля */} + + +
+ {/* Аватар */} +
+ + +
+ + {/* Форма никнейма */} +
+
+ + +
+
+
+
+
+ + {/* Статистика */} + + + + + Статистика + + + + {isLoadingStats ? ( +
+ +
+ ) : stats ? ( +
+
+ +
{stats.marathons_count}
+
Марафонов
+
+
+ +
{stats.wins_count}
+
Побед
+
+
+ +
{stats.completed_assignments}
+
Заданий
+
+
+ +
{stats.total_points_earned}
+
Очков
+
+
+ ) : ( +

Не удалось загрузить статистику

+ )} +
+
+ + {/* Telegram */} + + + + + Telegram + + + + {isLinked ? ( +
+
+
+ {user?.telegram_avatar_url ? ( + Telegram avatar + ) : ( + + )} +
+
+

+ {user?.telegram_first_name} {user?.telegram_last_name} +

+ {user?.telegram_username && ( +

@{user.telegram_username}

+ )} +
+ +
+
+ ) : ( +
+

+ Привяжи Telegram для получения уведомлений о событиях и марафонах. +

+ {isPolling ? ( +
+
+ +

Ожидание привязки...

+
+
+ ) : ( + + )} +
+ )} +
+
+ + {/* Смена пароля */} + + + + + Безопасность + + + + {!showPasswordForm ? ( + + ) : ( +
+
+ + +
+ +
+ + +
+ + + +
+ + +
+
+ )} +
+
+
+ ) +} diff --git a/frontend/src/pages/UserProfilePage.tsx b/frontend/src/pages/UserProfilePage.tsx new file mode 100644 index 0000000..fee5219 --- /dev/null +++ b/frontend/src/pages/UserProfilePage.tsx @@ -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(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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 ( +
+ +
+ ) + } + + if (error || !profile) { + return ( +
+ + + +

+ {error || 'Пользователь не найден'} +

+ + Вернуться на главную + +
+
+
+ ) + } + + return ( +
+ {/* Кнопка назад */} + + + {/* Профиль */} + + +
+ {/* Аватар */} +
+ {profile.avatar_url ? ( + {profile.nickname} + ) : ( +
+ +
+ )} +
+ + {/* Инфо */} +
+

+ {profile.nickname} +

+
+ + Зарегистрирован {formatDate(profile.created_at)} +
+
+
+
+
+ + {/* Статистика */} + + + + + Статистика + + + +
+
+ +
+ {profile.stats.marathons_count} +
+
Марафонов
+
+
+ +
+ {profile.stats.wins_count} +
+
Побед
+
+
+ +
+ {profile.stats.completed_assignments} +
+
Заданий
+
+
+ +
+ {profile.stats.total_points_earned} +
+
Очков
+
+
+
+
+
+ ) +} diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index e6e86cf..bcbc7a0 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -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' diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b51f4fa..3d72680 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 +}