{
+ const target = game.versions.find((v) => v.id === entry.versionId);
+ if (target) play(target, entry.code);
+ }} />
{canEdit && }
@@ -238,7 +246,7 @@ const KIND: Record = { original: "", translation: "Пе
function VersionRow(props: {
v: Version; depth: number; current: boolean; isMain: boolean; canManage: boolean; canFork: boolean;
canTranslate: boolean; canPublish: boolean;
- onPlay: () => void; onFork: () => void; onRetry: () => void; onDelete: () => void;
+ onPlay: () => void; onPlayLang: (code: string) => void; onFork: () => void; onRetry: () => void; onDelete: () => void;
onTranslate: () => void; onPublish: () => void; onMakeMain: () => void;
}) {
const { v } = props;
@@ -264,19 +272,10 @@ function VersionRow(props: {
{v.owner?.username ?? "—"} · {new Date(v.created_at).toLocaleDateString()} · {formatSize(v.size_bytes)}
{v.progress && ` · ${v.progress}`}
- {v.status === "ready" && v.languages?.length > 0 && (
-
-
- {v.languages.map((l) => (
-
- {l.name} {Math.round(l.progress * 100)}%
-
- ))}
-
- )}
{v.status === "ready" &&
}
+ {v.status === "ready" &&
props.onPlayLang(e.code)} />}
{v.status === "ready" && props.canFork && }
{v.status === "ready" && v.kind === "mod" && Редактор мода}
{v.status === "ready" && props.canTranslate && v.kind === "translation" && v.visibility === "draft" && props.canManage ? (
diff --git a/frontend/src/pages/languages.ts b/frontend/src/pages/languages.ts
new file mode 100644
index 0000000..9df93e3
--- /dev/null
+++ b/frontend/src/pages/languages.ts
@@ -0,0 +1,43 @@
+// Languages a game can be played in: per version, and for the whole game (all published versions).
+
+import type { Version } from "../api";
+
+export interface LangEntry {
+ code: string;
+ name: string;
+ /** 0..1 share of the text that is translated; 1 for the original language */
+ progress: number;
+ source: boolean;
+ /** the version that plays in this language */
+ versionId: number;
+}
+
+/** The original language first, then translations. */
+export function versionLanguages(v: Version): LangEntry[] {
+ const out: LangEntry[] = [];
+ if (v.source_lang && v.source_lang !== "und") {
+ out.push({ code: v.source_lang, name: v.source_name ?? v.source_lang, progress: 1, source: true, versionId: v.id });
+ }
+ for (const l of v.languages ?? []) {
+ if (!out.some((e) => e.code === l.code)) {
+ out.push({ code: l.code, name: l.name, progress: l.progress, source: false, versionId: v.id });
+ }
+ }
+ return out;
+}
+
+/** Languages of every published, ready version; each language points at the version that has it best. */
+export function aggregateLanguages(versions: Version[], preferred?: number | null): LangEntry[] {
+ const best = new Map();
+ for (const v of versions) {
+ if (v.status !== "ready" || v.visibility !== "published") continue;
+ for (const entry of versionLanguages(v)) {
+ const known = best.get(entry.code);
+ const better = !known || entry.progress > known.progress
+ || (entry.progress === known.progress && entry.versionId === preferred && known.versionId !== preferred);
+ if (better) best.set(entry.code, entry);
+ }
+ }
+ return [...best.values()].sort((a, b) =>
+ Number(b.source) - Number(a.source) || b.progress - a.progress || a.name.localeCompare(b.name));
+}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 751e138..96ddb21 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -838,3 +838,17 @@ input[type="file"] { padding: 0.4rem; }
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
+
+/* language dropdown (version row and game header) */
+.lang-menu { position: relative; display: inline-block; }
+.lang-menu .count { margin-left: 2px; opacity: 0.7; }
+.lang-menu-list {
+ position: absolute; z-index: 30; top: calc(100% + 6px); right: 0; min-width: 220px; margin: 0; padding: 6px;
+ list-style: none; background: var(--surface, #1b1f2b); border: 1px solid var(--border, #2b3040);
+ border-radius: 12px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
+}
+.lang-menu-item {
+ display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 10px;
+ border: 0; border-radius: 8px; background: transparent; color: inherit; font: inherit; cursor: pointer; text-align: left;
+}
+.lang-menu-item:hover, .lang-menu-item:focus-visible { background: rgba(255, 255, 255, 0.08); outline: none; }
diff --git a/frontend/src/ui/LanguageMenu.tsx b/frontend/src/ui/LanguageMenu.tsx
new file mode 100644
index 0000000..bd60940
--- /dev/null
+++ b/frontend/src/ui/LanguageMenu.tsx
@@ -0,0 +1,47 @@
+import { useEffect, useRef, useState } from "react";
+import type { LangEntry } from "../pages/languages";
+import { Icon } from "./Icon";
+
+/** A button that opens the list of languages a game or version can be played in. */
+export function LanguageMenu({ entries, onPick, title = "Языки" }: {
+ entries: LangEntry[]; onPick: (entry: LangEntry) => void; title?: string;
+}) {
+ const [open, setOpen] = useState(false);
+ const box = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+ const close = (ev: Event) => {
+ if (ev instanceof KeyboardEvent ? ev.key === "Escape" : !box.current?.contains(ev.target as Node)) setOpen(false);
+ };
+ document.addEventListener("mousedown", close);
+ document.addEventListener("keydown", close);
+ return () => {
+ document.removeEventListener("mousedown", close);
+ document.removeEventListener("keydown", close);
+ };
+ }, [open]);
+
+ if (!entries.length) return null;
+ return (
+
+
+ {open && (
+
+ {entries.map((e) => (
+ -
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/tests/languages.test.mjs b/frontend/tests/languages.test.mjs
new file mode 100644
index 0000000..b2039b6
--- /dev/null
+++ b/frontend/tests/languages.test.mjs
@@ -0,0 +1,62 @@
+import assert from "node:assert/strict";
+import { fileURLToPath } from "node:url";
+import { test } from "node:test";
+import { build } from "esbuild";
+
+const compiled = await build({ entryPoints: [fileURLToPath(new URL("../src/pages/languages.ts", import.meta.url))],
+ bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
+const { versionLanguages, aggregateLanguages } =
+ await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`);
+
+const version = (id, extra = {}) => ({ id, status: "ready", visibility: "published", source_lang: "en", source_name: "English",
+ languages: [], ...extra });
+const ru = (progress) => ({ code: "ru", name: "Русский", progress });
+
+test("a version lists its original language first, then its translations", () => {
+ const entries = versionLanguages(version(1, { languages: [ru(0.5), { code: "de", name: "Deutsch", progress: 1 }] }));
+ assert.deepEqual(entries.map((e) => [e.code, e.source, e.progress]), [["en", true, 1], ["ru", false, 0.5], ["de", false, 1]]);
+ assert.ok(entries.every((e) => e.versionId === 1));
+});
+
+test("an unknown original language is not shown, and a translation never repeats it", () => {
+ assert.deepEqual(versionLanguages(version(1, { source_lang: "und", source_name: null })), []);
+ assert.deepEqual(versionLanguages(version(1, { source_lang: null })), []);
+ const same = versionLanguages(version(1, { languages: [{ code: "en", name: "English", progress: 0.2 }] }));
+ assert.equal(same.length, 1);
+ assert.equal(same[0].source, true);
+});
+
+test("the game lists the languages of every published ready version", () => {
+ const entries = aggregateLanguages([
+ version(1),
+ version(2, { languages: [ru(0.4)] }),
+ version(3, { languages: [{ code: "fr", name: "Français", progress: 0.9 }] }),
+ ], 1);
+ assert.deepEqual(entries.map((e) => e.code), ["en", "fr", "ru"], "original first, then by progress");
+ assert.equal(entries.find((e) => e.code === "ru").versionId, 2);
+});
+
+test("drafts, unfinished and failed versions do not add languages", () => {
+ const entries = aggregateLanguages([
+ version(1),
+ version(2, { visibility: "draft", languages: [ru(1)] }),
+ version(3, { status: "converting", languages: [{ code: "de", name: "Deutsch", progress: 1 }] }),
+ version(4, { status: "failed", languages: [{ code: "fr", name: "Français", progress: 1 }] }),
+ ], 1);
+ assert.deepEqual(entries.map((e) => e.code), ["en"]);
+});
+
+test("when several versions have a language the most complete wins, the main one on a tie", () => {
+ const versions = [version(1, { languages: [ru(0.5)] }), version(2, { languages: [ru(0.9)] }), version(3, { languages: [ru(0.9)] })];
+ assert.equal(aggregateLanguages(versions, 1).find((e) => e.code === "ru").versionId, 2, "more complete");
+ assert.equal(aggregateLanguages(versions, 3).find((e) => e.code === "ru").versionId, 3, "main version on a tie");
+});
+
+test("an original language of one version counts as complete even if another has it as a translation", () => {
+ const entries = aggregateLanguages([version(1, { source_lang: "ru", source_name: "Русский" }),
+ version(2, { languages: [ru(0.3)] })], 1);
+ const russian = entries.find((e) => e.code === "ru");
+ assert.equal(russian.source, true);
+ assert.equal(russian.versionId, 1);
+ assert.equal(entries[0].code, "ru", "an original language is listed first");
+});