import { useRef, useState, type FormEvent } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Trash2, Upload } from 'lucide-react' import { ShellTopbar } from '@/components/layout/ShellTopbar' import { AppFooter } from '@/components/layout/AppFooter' import { Avatar } from '@/components/ui/Avatar' import { Select } from '@/components/ui/Select' import { useToast } from '@/components/ui/ToastProvider' import { useAuth } from '@/auth/useAuth' import { changePassword, deleteMyAvatar, listTeams, updateMe, uploadMyAvatar } from '@/api/users' import { MyBackgroundsSection } from '@/components/background/MyBackgroundsSection' import { usePublicSettings } from '@/hooks/usePublicSettings' import { ApiError, errorDetail } from '@/api/client' import '@/styles/lobby.css' import '@/styles/profile.css' /** Разрешённые типы файла аватара — те же ограничения, что и на backend. */ const ALLOWED_AVATAR_TYPES = ['image/jpeg', 'image/png', 'image/webp'] const MAX_AVATAR_BYTES = 2 * 1024 * 1024 /** * Страница профиля. Своя (текущий пользователь): * ФИО и команда редактируются, email — только чтение, аватар — загрузка с * клиентской предпроверкой типа/размера (дублирует лимиты backend — 413/415 * обрабатываются и на случай, если предпроверка почему-то разошлась с * реальным лимитом сервера). * * Справочник команд — аутентифицированный `GET /api/v1/teams` (не зависит * от тумблера «разрешить выбор команды при регистрации», в отличие от * `GET /auth/registration-options`, который для этого экрана не подходит). */ export function ProfilePage() { const { user, refreshUser } = useAuth() const toast = useToast() const queryClient = useQueryClient() const fileInputRef = useRef(null) const [name, setName] = useState(user?.name_user ?? '') const [teamId, setTeamId] = useState(user?.team_id ?? '') const [avatarError, setAvatarError] = useState(null) const { data: teams } = useQuery({ queryKey: ['teams'], queryFn: listTeams }) // Раздел «Свои фоны» есть, только пока модуль включён в админке — иначе // картинкам, которые никак не применить, в профиле не место (то же правило, // что и с кнопкой «Фон» в комнате: выключенный модуль не оставляет мёртвых // элементов интерфейса). const { data: publicSettings } = usePublicSettings() const teamOptions = [{ value: '', label: 'Без команды' }, ...((teams ?? []).map((t) => ({ value: t.id, label: t.name })))] async function afterProfileChange() { await refreshUser() queryClient.invalidateQueries({ queryKey: ['conferences'] }) } const saveMutation = useMutation({ mutationFn: () => updateMe({ name_user: name.trim(), team_id: teamId === '' ? null : teamId }), onSuccess: async () => { await afterProfileChange() toast.show('Профиль сохранён', 'success') }, onError: () => toast.show('Не удалось сохранить профиль', 'error'), }) const avatarMutation = useMutation({ mutationFn: (file: File) => uploadMyAvatar(file), onSuccess: async () => { await afterProfileChange() toast.show('Аватар обновлён', 'success') }, onError: (err: unknown) => { if (err instanceof ApiError && err.status === 413) { toast.show('Файл слишком большой — максимум 2 МБ', 'error') } else if (err instanceof ApiError && err.status === 415) { toast.show('Недопустимый формат — только JPEG, PNG или WEBP', 'error') } else { toast.show('Не удалось загрузить аватар', 'error') } }, }) const deleteAvatarMutation = useMutation({ mutationFn: () => deleteMyAvatar(), onSuccess: async () => { await afterProfileChange() toast.show('Аватар удалён', 'success') }, onError: () => toast.show('Не удалось удалить аватар', 'error'), }) const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('') const [passwordError, setPasswordError] = useState(null) const passwordMutation = useMutation({ mutationFn: () => changePassword({ current_password: currentPassword, new_password: newPassword }), onSuccess: () => { toast.show('Пароль изменён', 'success') setCurrentPassword('') setNewPassword('') setConfirmPassword('') }, onError: (err: unknown) => { if (err instanceof ApiError && err.status === 400 && errorDetail(err) === 'invalid_current_password') { setPasswordError('Неверный текущий пароль') } else { toast.show('Не удалось сменить пароль', 'error') } }, }) function handlePasswordSubmit(event: FormEvent) { event.preventDefault() setPasswordError(null) if (newPassword.length < 8) { setPasswordError('Новый пароль должен быть не короче 8 символов') return } if (newPassword !== confirmPassword) { setPasswordError('Пароли не совпадают') return } passwordMutation.mutate() } function handleFileChange(file: File | null) { setAvatarError(null) if (!file) return if (!ALLOWED_AVATAR_TYPES.includes(file.type)) { setAvatarError('Недопустимый формат — только JPEG, PNG или WEBP') return } if (file.size > MAX_AVATAR_BYTES) { setAvatarError('Файл слишком большой — максимум 2 МБ') return } avatarMutation.mutate(file) } if (!user) return null return (

Профиль

Личные данные, команда и фото — видны другим участникам в составе конференций.

{user.avatar_url && ( )} { handleFileChange(e.target.files?.[0] ?? null) e.target.value = '' }} />
{avatarError &&

{avatarError}

}
{ e.preventDefault() saveMutation.mutate() }} >
setName(e.target.value)} maxLength={255} />
setCurrentPassword(e.target.value)} />
setNewPassword(e.target.value)} />
setConfirmPassword(e.target.value)} /> {passwordError && (

{passwordError}

)}
) }