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
+8 -2
View File
@@ -68,6 +68,8 @@ export function apiErrorField(error: unknown, key: string): unknown {
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
body?: unknown;
/** Тело multipart (загрузка файлов): заменяет JSON-тело. */
form?: FormData;
signal?: AbortSignal;
headers?: Record<string, string>;
}
@@ -87,16 +89,20 @@ export function parsePayload(text: string, contentType: string | null): unknown
}
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
// Multipart отдаёт браузер: Content-Type с boundary выставляет он сам.
const isForm = options.form !== undefined;
const init: RequestInit = {
method: options.method ?? 'GET',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
...(options.body === undefined || isForm ? {} : { 'Content-Type': 'application/json' }),
...options.headers,
},
};
if (options.body !== undefined) {
if (isForm) {
init.body = options.form ?? null;
} else if (options.body !== undefined) {
init.body = JSON.stringify(options.body);
}
if (options.signal !== undefined) {