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