Первоначальная версия VidConf
This commit is contained in:
276
frontend/src/pages/ProfilePage.tsx
Normal file
276
frontend/src/pages/ProfilePage.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
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 { 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 { 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<HTMLInputElement>(null)
|
||||
|
||||
const [name, setName] = useState(user?.name_user ?? '')
|
||||
const [teamId, setTeamId] = useState(user?.team_id ?? '')
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null)
|
||||
|
||||
const { data: teams } = useQuery({ queryKey: ['teams'], queryFn: listTeams })
|
||||
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<string | null>(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<HTMLFormElement>) {
|
||||
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 (
|
||||
<div className="page-shell">
|
||||
<ShellTopbar />
|
||||
<main className="page-main">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>Профиль</h1>
|
||||
<p>Личные данные, команда и фото — видны другим участникам в составе конференций.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="profile-card">
|
||||
<div className="profile-avatar-block">
|
||||
<Avatar name={user.name_user} avatarUrl={user.avatar_url} size={88} />
|
||||
<div className="profile-avatar-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={avatarMutation.isPending}
|
||||
>
|
||||
<Upload style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
{avatarMutation.isPending ? 'Загружаем…' : 'Загрузить фото'}
|
||||
</button>
|
||||
{user.avatar_url && (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
aria-label="Удалить аватар"
|
||||
onClick={() => deleteAvatarMutation.mutate()}
|
||||
disabled={deleteAvatarMutation.isPending}
|
||||
>
|
||||
<Trash2 style={{ width: 16, height: 16 }} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
handleFileChange(e.target.files?.[0] ?? null)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{avatarError && <p className="field-hint" style={{ color: 'var(--color-danger)' }}>{avatarError}</p>}
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
saveMutation.mutate()
|
||||
}}
|
||||
>
|
||||
<div className="field">
|
||||
<label htmlFor="profile-name">ФИО</label>
|
||||
<input id="profile-name" type="text" value={name} onChange={(e) => setName(e.target.value)} maxLength={255} />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="profile-email">Email</label>
|
||||
<input id="profile-email" type="email" value={user.email} disabled readOnly />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label id="profile-team-label" htmlFor="profile-team">
|
||||
Команда
|
||||
</label>
|
||||
<Select
|
||||
id="profile-team"
|
||||
aria-labelledby="profile-team-label"
|
||||
value={teamId ?? ''}
|
||||
onChange={setTeamId}
|
||||
options={teamOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary" disabled={saveMutation.isPending || !name.trim()}>
|
||||
{saveMutation.isPending ? 'Сохраняем…' : 'Сохранить изменения'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="profile-card">
|
||||
<h2>Смена пароля</h2>
|
||||
<form onSubmit={handlePasswordSubmit}>
|
||||
<div className="field">
|
||||
<label htmlFor="profile-current-password">Текущий пароль</label>
|
||||
<input
|
||||
id="profile-current-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="profile-new-password">Новый пароль</label>
|
||||
<input
|
||||
id="profile-new-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
placeholder="Минимум 8 символов"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`field${passwordError ? ' has-error' : ''}`}>
|
||||
<label htmlFor="profile-confirm-password">Подтверждение пароля</label>
|
||||
<input
|
||||
id="profile-confirm-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
{passwordError && (
|
||||
<p className="field-hint" style={{ color: 'var(--color-danger)' }}>
|
||||
{passwordError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={passwordMutation.isPending || !currentPassword || !newPassword || !confirmPassword}
|
||||
>
|
||||
{passwordMutation.isPending ? 'Сохраняем…' : 'Сменить пароль'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user