feat(web): вход с 2FA сразу, панель пользователя, модальные настройки, markdown
По правкам пользователя: - вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход; - нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором устройства и громкости, переход в «Аудио-видео») и шестерёнка; - кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»); - настройки открываются модальным окном со списком пунктов; «Профиль» имеет вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по умолчанию); - markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал; - двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и при выделенном тексте); - 219 тестов Vitest (было 176), check/lint/test/build зелёные.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface User {
|
||||
is_instance_admin: boolean;
|
||||
badges: string[];
|
||||
locale: string;
|
||||
/** Часовой пояс в формате IANA; сервер может его не отдавать. */
|
||||
timezone?: string;
|
||||
onboarding_completed: boolean;
|
||||
/** Включена ли 2FA: от неё зависит требование кода при step-up. */
|
||||
totp_enabled: boolean;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user