feat(web): вход с 2FA сразу, панель пользователя, модальные настройки, markdown

По правкам пользователя:

- вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные
  резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход;
- нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором
  статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором
  устройства и громкости, переход в «Аудио-видео») и шестерёнка;
- кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»);
- настройки открываются модальным окном со списком пунктов; «Профиль» имеет
  вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем
  серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по
  умолчанию);
- markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал;
- двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и
  при выделенном тексте);
- 219 тестов Vitest (было 176), check/lint/test/build зелёные.
This commit is contained in:
2026-09-20 01:32:11 +03:00
parent f26728113d
commit 20eed227ca
31 changed files with 2731 additions and 478 deletions
+28
View File
@@ -8,6 +8,8 @@ export interface UpdateProfileInput {
custom_status?: string;
custom_status_emoji?: string;
locale?: string;
/** Часовой пояс в формате IANA (например `Europe/Moscow`). */
timezone?: string;
}
export interface OnboardingInput {
@@ -44,6 +46,32 @@ export async function completeOnboarding(input: OnboardingInput): Promise<User>
return payload.user;
}
/** Максимальный размер аватара: сервер отвечает `file.too_large` выше лимита. */
export const AVATAR_MAX_BYTES = 8 * 1024 * 1024;
/**
* Загрузка аватара: `POST /users/@me/avatar` multipart с полем `file`.
*
* Сервер может ответить как полным профилем, так и только идентификатором
* файла, поэтому возвращаем профиль, если он есть, иначе `null` — вызывающий
* код в этом случае перечитывает `GET /users/@me`.
*/
export async function uploadAvatar(file: File): Promise<User | null> {
const form = new FormData();
form.append('file', file);
const payload = await request<{ user?: User }>('/users/@me/avatar', {
method: 'POST',
form,
});
return payload.user ?? null;
}
/** Удаление аватара: `DELETE /users/@me/avatar`. */
export async function deleteAvatar(): Promise<User | null> {
const payload = await request<{ user?: User }>('/users/@me/avatar', { method: 'DELETE' });
return payload.user ?? null;
}
export async function fetchMyGuilds(signal?: AbortSignal): Promise<GuildSummary[]> {
const payload = await request<{ guilds: GuildSummary[] }>(
'/users/@me/guilds',