feat(web): заготовка веб-клиента на Vite + React 19 + TypeScript strict
- стек по §5.2: React 19, React Router, TanStack Query, Zustand, Tailwind CSS 4, i18next (ru/en), Vitest + Testing Library - дизайн-токены §3 docs/client-web.md (тёмная тема по умолчанию, светлая, accent) - типизированный API-клиент §8.1: единый формат ошибок, ApiError с кодом - App: экран состояния фундамента с readz/meta, локализация ошибок по code - тесты: клиент API, форматирование, рендер App (успех/ошибка кода/сеть) - eslint + prettier + tsc strict; initial JS 97.6 KB gzip
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
||||
body?: unknown;
|
||||
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> {
|
||||
const init: RequestInit = {
|
||||
method: options.method ?? 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
...options.headers,
|
||||
},
|
||||
};
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { request } from './client';
|
||||
|
||||
export interface ReadinessResponse {
|
||||
status: 'ready' | 'unavailable';
|
||||
version: string;
|
||||
checks: Record<string, string>;
|
||||
}
|
||||
|
||||
export const readyQueryKey = ['readyz'] as const;
|
||||
|
||||
export function fetchReadiness(signal?: AbortSignal): Promise<ReadinessResponse> {
|
||||
return request<ReadinessResponse>('/readyz', signal === undefined ? {} : { signal });
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { request } from './client';
|
||||
|
||||
export interface InstanceFeatures {
|
||||
registration_enabled: boolean;
|
||||
anti_bot_enabled: boolean;
|
||||
voice_enabled: boolean;
|
||||
web_push_enabled: boolean;
|
||||
oauth_enabled: boolean;
|
||||
passkeys_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface InstanceMeta {
|
||||
name: string;
|
||||
version: string;
|
||||
commit: string;
|
||||
build_date: string;
|
||||
api_version: string;
|
||||
base_url: string;
|
||||
files_url: string;
|
||||
gateway_url: string;
|
||||
rtc_path: string;
|
||||
max_upload_size: number;
|
||||
features: InstanceFeatures;
|
||||
}
|
||||
|
||||
export const metaQueryKey = ['meta'] as const;
|
||||
|
||||
export function fetchMeta(signal?: AbortSignal): Promise<InstanceMeta> {
|
||||
return request<InstanceMeta>('/meta', signal === undefined ? {} : { signal });
|
||||
}
|
||||
Reference in New Issue
Block a user