Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View 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>
)
}