feat: language dropdowns on versions and on the game page
CI / frontend (push) Failing after 20s
CI / backend (push) Successful in 51s

- 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>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 18:41:26 +03:00
1 parent a93f33c93d
commit a1f5ef8e2d
9 files changed
+205 -16

No files matched your search

+1
View File
@@ -82,6 +82,7 @@ class VersionOut(ORM):
kind: str = "original" kind: str = "original"
# from the version's game.json (filled by the game detail endpoint) # from the version's game.json (filled by the game detail endpoint)
source_lang: str | None = None source_lang: str | None = None
source_name: str | None = None
languages: list[dict[str, Any]] = [] languages: list[dict[str, Any]] = []
+4 -2
View File
@@ -127,10 +127,12 @@ def _image_file(images: dict[str, Any], img: dict[str, Any] | None) -> str | Non
def version_languages(manifest: dict[str, Any] | None) -> dict[str, Any]: def version_languages(manifest: dict[str, Any] | None) -> dict[str, Any]:
"""Source language and translations of a version, for listings.""" """Source language and translations of a version, for listings."""
if not manifest or "game.json" not in manifest.get("files", {}): if not manifest or "game.json" not in manifest.get("files", {}):
return {"source_lang": None, "languages": []} return {"source_lang": None, "source_name": None, "languages": []}
game = file_json(manifest, "game.json") game = file_json(manifest, "game.json")
source = game.get("sourceLang")
return { return {
"source_lang": game.get("sourceLang"), "source_lang": source,
"source_name": LANG_NAMES.get(source, source) if source and source != "und" else None,
"languages": [{"code": code, "name": info.get("name", code), "languages": [{"code": code, "name": info.get("name", code),
"progress": info.get("progress", 0)} "progress": info.get("progress", 0)}
for code, info in sorted((game.get("locales") or {}).items())], for code, info in sorted((game.get("locales") or {}).items())],
+20
View File
@@ -0,0 +1,20 @@
from app import translation as tr
def manifest():
return {"files": {"game.json": {"hash": "x", "size": 1}}}
def test_source_language_name_and_translations(monkeypatch):
game = {"sourceLang": "en", "locales": {"ru": {"name": "Русский", "progress": 0.5}, "de": {"progress": 1}}}
monkeypatch.setattr(tr, "file_json", lambda m, path: game)
result = tr.version_languages(manifest())
assert result["source_lang"] == "en"
assert result["source_name"] == tr.LANG_NAMES["en"]
assert [(lang["code"], lang["progress"]) for lang in result["languages"]] == [("de", 1), ("ru", 0.5)]
def test_unknown_or_missing_source_language_has_no_name(monkeypatch):
monkeypatch.setattr(tr, "file_json", lambda m, path: {"sourceLang": "und"})
assert tr.version_languages(manifest())["source_name"] is None
assert tr.version_languages(None) == {"source_lang": None, "source_name": None, "languages": []}
+1
View File
@@ -32,6 +32,7 @@ export interface Version {
visibility: "draft" | "published"; visibility: "draft" | "published";
kind: "original" | "translation" | "mod"; kind: "original" | "translation" | "mod";
source_lang: string | null; source_lang: string | null;
source_name?: string | null;
languages: { code: string; name: string; progress: number }[]; languages: { code: string; name: string; progress: number }[];
progress: string; progress: string;
size_bytes: number; size_bytes: number;
+13 -14
View File
@@ -6,6 +6,8 @@ import { getRecent, pushRecent } from "../recent";
import { Cover, ErrorNote, Spinner } from "../ui/Bits"; import { Cover, ErrorNote, Spinner } from "../ui/Bits";
import { F95Fetch } from "../ui/F95Fetch"; import { F95Fetch } from "../ui/F95Fetch";
import { Icon } from "../ui/Icon"; import { Icon } from "../ui/Icon";
import { LanguageMenu } from "../ui/LanguageMenu";
import { aggregateLanguages, versionLanguages } from "./languages";
import { useUi } from "../ui/Ui"; import { useUi } from "../ui/Ui";
import { useUploads } from "../uploads"; import { useUploads } from "../uploads";
@@ -74,9 +76,9 @@ export function GamePage() {
} }
}; };
const play = (v: Version) => { const play = (v: Version, lang?: string) => {
pushRecent({ gameId: game.id, versionId: v.id, title: game.title, coverHash: game.cover_hash }); pushRecent({ gameId: game.id, versionId: v.id, title: game.title, coverHash: game.cover_hash });
navigate(`/play/${v.id}`); navigate(`/play/${v.id}${lang ? `?lang=${encodeURIComponent(lang)}` : ""}`);
}; };
const fork = async (v: Version) => { const fork = async (v: Version) => {
@@ -167,7 +169,7 @@ export function GamePage() {
canManage={v.owner?.id === me?.id || can("versions.manage_any") || can("games.delete")} canManage={v.owner?.id === me?.id || can("versions.manage_any") || can("games.delete")}
canTranslate={can("translations.edit")} canPublish={can("versions.publish")} canTranslate={can("translations.edit")} canPublish={can("versions.publish")}
onTranslate={() => void translate(v)} onPublish={() => void publish(v)} onMakeMain={() => void makeMain(v)} onTranslate={() => void translate(v)} onPublish={() => void publish(v)} onMakeMain={() => void makeMain(v)}
canFork={can("versions.fork")} onPlay={() => play(v)} onFork={() => void fork(v)} canFork={can("versions.fork")} onPlay={() => play(v)} onPlayLang={(code) => play(v, code)} onFork={() => void fork(v)}
onRetry={() => void run(() => api.post(`/api/versions/${v.id}/retry`))} onRetry={() => void run(() => api.post(`/api/versions/${v.id}/retry`))}
onDelete={() => void removeVersion(v)} />, onDelete={() => void removeVersion(v)} />,
...renderTree(v.id, depth + 1), ...renderTree(v.id, depth + 1),
@@ -197,6 +199,12 @@ export function GamePage() {
<span className="badge badge-warn busy">{busy ? "Идёт конвертация…" : "Нет готовых версий"}</span> <span className="badge badge-warn busy">{busy ? "Идёт конвертация…" : "Нет готовых версий"}</span>
)} )}
{playVersion && game.versions.length > 1 && <span className="muted small">версия «{playVersion.name}»</span>} {playVersion && game.versions.length > 1 && <span className="muted small">версия «{playVersion.name}»</span>}
<LanguageMenu title="Языки всех опубликованных версий"
entries={aggregateLanguages(game.versions, game.main_version_id)}
onPick={(entry) => {
const target = game.versions.find((v) => v.id === entry.versionId);
if (target) play(target, entry.code);
}} />
</div> </div>
<div className="actions"> <div className="actions">
{canEdit && <button className="btn btn-sm" onClick={() => setEditing(true)}><Icon name="edit" size={16} />Редактировать</button>} {canEdit && <button className="btn btn-sm" onClick={() => setEditing(true)}><Icon name="edit" size={16} />Редактировать</button>}
@@ -238,7 +246,7 @@ const KIND: Record<Version["kind"], string> = { original: "", translation: "Пе
function VersionRow(props: { function VersionRow(props: {
v: Version; depth: number; current: boolean; isMain: boolean; canManage: boolean; canFork: boolean; v: Version; depth: number; current: boolean; isMain: boolean; canManage: boolean; canFork: boolean;
canTranslate: boolean; canPublish: 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; onTranslate: () => void; onPublish: () => void; onMakeMain: () => void;
}) { }) {
const { v } = props; const { v } = props;
@@ -264,19 +272,10 @@ function VersionRow(props: {
{v.owner?.username ?? "—"} · {new Date(v.created_at).toLocaleDateString()} · {formatSize(v.size_bytes)} {v.owner?.username ?? "—"} · {new Date(v.created_at).toLocaleDateString()} · {formatSize(v.size_bytes)}
{v.progress && ` · ${v.progress}`} {v.progress && ` · ${v.progress}`}
</div> </div>
{v.status === "ready" && v.languages?.length > 0 && (
<div className="tags" style={{ marginTop: "0.35rem" }} title="Языки версии">
<Icon name="globe" size={14} />
{v.languages.map((l) => (
<Link key={l.code} className="tag" to={`/versions/${v.id}/translate?lang=${l.code}`}>
{l.name} {Math.round(l.progress * 100)}%
</Link>
))}
</div>
)}
</div> </div>
<div className="actions"> <div className="actions">
{v.status === "ready" && <button className="btn btn-primary btn-sm" onClick={props.onPlay}><Icon name="play" size={16} />Играть</button>} {v.status === "ready" && <button className="btn btn-primary btn-sm" onClick={props.onPlay}><Icon name="play" size={16} />Играть</button>}
{v.status === "ready" && <LanguageMenu title="Языки версии" entries={versionLanguages(v)} onPick={(e) => props.onPlayLang(e.code)} />}
{v.status === "ready" && props.canFork && <button className="btn btn-sm" onClick={props.onFork}><Icon name="fork" size={16} />Форк</button>} {v.status === "ready" && props.canFork && <button className="btn btn-sm" onClick={props.onFork}><Icon name="fork" size={16} />Форк</button>}
{v.status === "ready" && v.kind === "mod" && <Link className="btn btn-sm" to={`/versions/${v.id}/edit`}>Редактор мода</Link>} {v.status === "ready" && v.kind === "mod" && <Link className="btn btn-sm" to={`/versions/${v.id}/edit`}>Редактор мода</Link>}
{v.status === "ready" && props.canTranslate && v.kind === "translation" && v.visibility === "draft" && props.canManage ? ( {v.status === "ready" && props.canTranslate && v.kind === "translation" && v.visibility === "draft" && props.canManage ? (
+43
View File
@@ -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<string, LangEntry>();
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));
}
+14
View File
@@ -838,3 +838,17 @@ input[type="file"] { padding: 0.4rem; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } * { 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; }
+47
View File
@@ -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<HTMLDivElement>(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 (
<div className="lang-menu" ref={box}>
<button className="btn btn-sm" aria-haspopup="menu" aria-expanded={open} title={title} onClick={() => setOpen(!open)}>
<Icon name="globe" size={16} />Языки<span className="count">{entries.length}</span>
<Icon name="chevron" size={14} />
</button>
{open && (
<ul className="lang-menu-list" role="menu">
{entries.map((e) => (
<li key={e.code} role="none">
<button role="menuitem" className="lang-menu-item" title={`Играть: ${e.name}`}
onClick={() => { setOpen(false); onPick(e); }}>
<span>{e.name}</span>
<span className="muted small">{e.source ? "оригинал" : `${Math.round(e.progress * 100)}%`}</span>
</button>
</li>
))}
</ul>
)}
</div>
);
}
+62
View File
@@ -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");
});