Files
vidconf/frontend/src/pages/ProfilePage.tsx
Max Ronzhin 17437880b1 feat(room): замена фона видео на картинку — только на десктопе
Кнопка «Фон» в тулбаре комнаты и выбор фона в превью на входе: три готовые
сцены и свои картинки из профиля. Фон применяется процессором к самому
публикуемому треку (`LocalVideoTrack.setProcessor`), а НЕ пересозданием
`RoomOptions` — ссылка на них обязана оставаться стабильной, иначе
`LiveKitRoom` переподключается к комнате.

Фича только для ДЕСКТОПА, и «десктоп» определяется по возможностям устройства
(`pointer: fine` + `hover: hover` + `maxTouchPoints`), а НЕ по ширине окна:
узкое окно на десктопе — всё ещё десктоп, а широкий планшет — всё ещё планшет,
который сегментация греет. На мобильном кнопки нет вовсе, а не задизейбленной.

Ассеты сегментации отдаются СО СВОЕГО домена: библиотека по умолчанию тянет
wasm с jsdelivr, а модель с storage.googleapis.com, и в закрытом контуре фича
молча не работала бы. Модель (Apache 2.0, см. NOTICE.txt) лежит в репозитории,
wasm-рантайм (~19 МБ) копируется из node_modules плагином сборки. Сама
библиотека и модель грузятся ЛЕНИВО — только когда фон реально включают, вход
в конференцию не стал медленнее.

Три дефолтные сцены — собственные векторные рисунки (`design/backgrounds/`),
а не фотографии из интернета: у нарисованной сцены нет чужой лицензии, а
продукт расходится по инсталляциям, и проверять права на каждую копию некому.

Свои картинки — в профиле, до 10 штук, с уменьшением до 1280px и переводом в
WebP прямо в браузере перед отправкой. Удаление применённого сейчас фона
сбрасывает выбор на «без фона»: хранится ключ записи, а не URL картинки.

Смена камеры фон не теряет (`restartTrack` перезапускает процессор сам),
выключение и включение камеры — навешивает его на новый трек заново.

⚠️ Прокси dev-сервера для `/media/` — обязательно со слэшем: ключ `/media`
Vite матчит префиксом и перехватывает заодно `/mediapipe/...`, из-за чего
модель получала 404 и фон молча не включался.
2026-08-10 08:59:47 +03:00

288 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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 { 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<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>
{publicSettings?.virtual_background_enabled && <MyBackgroundsSection />}
<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>
<AppFooter />
</div>
)
}