Update GPT and add Profile

This commit is contained in:
2025-12-16 22:12:12 +07:00
parent 08b96fd1f7
commit 696dc714c4
15 changed files with 1063 additions and 90 deletions

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
}