Files
pgames-manager/frontend/src/api.ts
T
mrleo1nidandClaude Sonnet 5.5 a1f5ef8e2d
CI / frontend (push) Failing after 20s
CI / backend (push) Successful in 51s
feat: language dropdowns on versions and on the game page
- Each ready version gets a "Languages" dropdown next to Play: the original language
  first, then translations with their completion; picking one starts the version in
  that language
- The game header gets the same dropdown, aggregated over published, ready versions.
  Each language points at the version that has it best (most complete, the main
  version on a tie); an original language counts as complete
- The old row of language tags linking to the translation editor is replaced
- Backend: versions carry source_name (the name of the original language)
- pages/languages.ts holds the aggregation logic with tests; LanguageMenu closes on
  outside click and Escape

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 18:41:26 +03:00

132 lines
3.9 KiB
TypeScript

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<string, any> | 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<T>(method: string, url: string, body?: unknown): Promise<T> {
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: <T>(url: string) => request<T>("GET", url),
post: <T>(url: string, body?: unknown) => request<T>("POST", url, body ?? {}),
put: <T>(url: string, body?: unknown) => request<T>("PUT", url, body ?? {}),
patch: <T>(url: string, body?: unknown) => request<T>("PATCH", url, body ?? {}),
del: (url: string) => request<void>("DELETE", url),
};
/** Upload a raw body with progress (fetch has no upload progress). */
export function uploadRaw<T>(method: string, url: string, file: Blob,
onProgress: (fraction: number) => void): Promise<T> {
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]}`;
}