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>
This commit is contained in:
1 parent
a93f33c93d
commit
a1f5ef8e2d
9 files changed
+205
-16
No files matched your search
@@ -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]] = []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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())],
|
||||||
|
|||||||
@@ -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": []}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
Reference in new issue
Block a user