- 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>
132 lines
3.9 KiB
TypeScript
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]}`;
|
|
}
|