Кнопка «Фон» в тулбаре комнаты и выбор фона в превью на входе: три готовые
сцены и свои картинки из профиля. Фон применяется процессором к самому
публикуемому треку (`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 и фон молча не включался.
288 lines
12 KiB
TypeScript
288 lines
12 KiB
TypeScript
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>
|
||
)
|
||
}
|