Files
glchat/web/src/api/client.ts
T
grendervill 20eed227ca feat(web): вход с 2FA сразу, панель пользователя, модальные настройки, markdown
По правкам пользователя:

- вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные
  резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход;
- нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором
  статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором
  устройства и громкости, переход в «Аудио-видео») и шестерёнка;
- кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»);
- настройки открываются модальным окном со списком пунктов; «Профиль» имеет
  вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем
  серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по
  умолчанию);
- markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал;
- двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и
  при выделенном тексте);
- 219 тестов Vitest (было 176), check/lint/test/build зелёные.
2026-09-20 01:32:11 +03:00

139 lines
4.2 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.
export interface ApiErrorBody {
error: {
code: string;
message: string;
details?: Record<string, unknown>;
};
}
export class ApiError extends Error {
readonly code: string;
readonly status: number;
readonly details?: Record<string, unknown>;
constructor(status: number, code: string, message: string, details?: Record<string, unknown>) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
if (details !== undefined) {
this.details = details;
}
}
}
const API_PREFIX = '/api/v1';
export function isApiErrorPayload(value: unknown): value is ApiErrorBody['error'] {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as { code?: unknown; message?: unknown };
return typeof candidate.code === 'string' && typeof candidate.message === 'string';
}
export function apiErrorCode(error: unknown): string | null {
if (error === null || typeof error !== 'object') {
return null;
}
const payload = (error as { apiError?: unknown }).apiError;
return isApiErrorPayload(payload) ? payload.code : null;
}
/**
* Дополнительное поле конверта ошибки (например, `retry_after_ms` у 429).
*
* Сервер кладёт детали рядом с `code`/`message`, а не во вложенный `details`,
* поэтому ищем значение в исходном конверте; `details` остаётся запасным
* вариантом для ошибок, собранных вручную.
*/
export function apiErrorField(error: unknown, key: string): unknown {
if (error === null || typeof error !== 'object') {
return undefined;
}
const record = error as { apiError?: unknown; details?: unknown };
if (isApiErrorPayload(record.apiError)) {
const value = (record.apiError as unknown as Record<string, unknown>)[key];
if (value !== undefined) {
return value;
}
}
const details = record.details;
if (typeof details === 'object' && details !== null) {
return (details as Record<string, unknown>)[key];
}
return undefined;
}
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
body?: unknown;
/** Тело multipart (загрузка файлов): заменяет JSON-тело. */
form?: FormData;
signal?: AbortSignal;
headers?: Record<string, string>;
}
export function parsePayload(text: string, contentType: string | null): unknown {
if (text.length === 0) {
return null;
}
if (contentType !== null && !contentType.includes('application/json')) {
return null;
}
try {
return JSON.parse(text) as unknown;
} catch {
return null;
}
}
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 || isForm ? {} : { 'Content-Type': 'application/json' }),
...options.headers,
},
};
if (isForm) {
init.body = options.form ?? null;
} else if (options.body !== undefined) {
init.body = JSON.stringify(options.body);
}
if (options.signal !== undefined) {
init.signal = options.signal;
}
const response = await fetch(`${API_PREFIX}${path}`, init);
const text = await response.text();
const payload = parsePayload(text, response.headers.get('Content-Type'));
if (!response.ok) {
const envelope =
typeof payload === 'object' && payload !== null
? (payload as { error?: unknown }).error
: undefined;
if (isApiErrorPayload(envelope)) {
const error = new ApiError(
response.status,
envelope.code,
envelope.message,
envelope.details,
);
Object.defineProperty(error, 'apiError', {
value: envelope,
enumerable: false,
writable: false,
});
throw error;
}
throw new ApiError(response.status, 'internal.error', 'unexpected response');
}
return payload as T;
}