export interface Role { id: number; name: string; permissions: string[]; is_system: boolean; } export interface User { id: number; username: string; role: Role; is_active: boolean; must_change_password: boolean; must_change_username: boolean; created_at: string; } export interface Me extends User { permissions: string[]; } export interface Version { revision_id: string | null; base_revision_id: string | null; id: number; game_id: number; parent_id: number | null; owner: { id: number; username: string } | null; name: string; description: string; status: "uploading" | "queued" | "converting" | "importing" | "ready" | "failed"; visibility: "draft" | "published"; kind: "original" | "translation" | "mod"; source_lang: string | null; source_name?: string | null; languages: { code: string; name: string; progress: number }[]; progress: string; size_bytes: number; created_at: string; report?: Record | null; } export interface Game { id: number; title: string; description: string; tags: string[]; engine: string; cover_hash: string | null; main_version_id: number | null; created_at: string; ready_versions?: number; pending_versions?: number; failed_versions?: number; } export interface GameDetail extends Game { versions: Version[]; } export class ApiError extends Error { constructor(public status: number, message: string) { super(message); } } async function request(method: string, url: string, body?: unknown): Promise { const r = await fetch(url, { method, credentials: "same-origin", headers: body !== undefined ? { "Content-Type": "application/json" } : undefined, body: body !== undefined ? JSON.stringify(body) : undefined, }); if (!r.ok) { let msg = r.statusText; try { const data = await r.json(); msg = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail); } catch { /* not json */ } if (r.status === 401) window.dispatchEvent(new Event("pg-unauthorized")); throw new ApiError(r.status, msg); } return r.status === 204 ? (undefined as T) : r.json(); } export const api = { get: (url: string) => request("GET", url), post: (url: string, body?: unknown) => request("POST", url, body ?? {}), put: (url: string, body?: unknown) => request("PUT", url, body ?? {}), patch: (url: string, body?: unknown) => request("PATCH", url, body ?? {}), del: (url: string) => request("DELETE", url), }; /** Upload a raw body with progress (fetch has no upload progress). */ export function uploadRaw(method: string, url: string, file: Blob, onProgress: (fraction: number) => void): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open(method, url); xhr.setRequestHeader("Content-Type", "application/octet-stream"); xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total); xhr.onload = () => { let data: any = null; try { data = JSON.parse(xhr.responseText); } catch { /* empty */ } if (xhr.status >= 200 && xhr.status < 300) resolve(data); else if (xhr.status === 413 && !data?.detail) reject(new ApiError(413, "Файл слишком большой для прокси перед сервером (client_max_body_size)")); else reject(new ApiError(xhr.status, data?.detail ?? (xhr.statusText || `Ошибка ${xhr.status}`))); }; xhr.onerror = () => reject(new ApiError(0, "Сетевая ошибка")); xhr.send(file); }); } export function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} Б`; const units = ["КБ", "МБ", "ГБ", "ТБ"]; let v = bytes / 1024; let i = 0; while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`; }