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"
|
||||
# from the version's game.json (filled by the game detail endpoint)
|
||||
source_lang: str | None = None
|
||||
source_name: str | None = None
|
||||
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]:
|
||||
"""Source language and translations of a version, for listings."""
|
||||
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")
|
||||
source = game.get("sourceLang")
|
||||
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),
|
||||
"progress": info.get("progress", 0)}
|
||||
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";
|
||||
kind: "original" | "translation" | "mod";
|
||||
source_lang: string | null;
|
||||
source_name?: string | null;
|
||||
languages: { code: string; name: string; progress: number }[];
|
||||
progress: string;
|
||||
size_bytes: number;
|
||||
|
||||
@@ -6,6 +6,8 @@ import { getRecent, pushRecent } from "../recent";
|
||||
import { Cover, ErrorNote, Spinner } from "../ui/Bits";
|
||||
import { F95Fetch } from "../ui/F95Fetch";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { LanguageMenu } from "../ui/LanguageMenu";
|
||||
import { aggregateLanguages, versionLanguages } from "./languages";
|
||||
import { useUi } from "../ui/Ui";
|
||||
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 });
|
||||
navigate(`/play/${v.id}`);
|
||||
navigate(`/play/${v.id}${lang ? `?lang=${encodeURIComponent(lang)}` : ""}`);
|
||||
};
|
||||
|
||||
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")}
|
||||
canTranslate={can("translations.edit")} canPublish={can("versions.publish")}
|
||||
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`))}
|
||||
onDelete={() => void removeVersion(v)} />,
|
||||
...renderTree(v.id, depth + 1),
|
||||
@@ -197,6 +199,12 @@ export function GamePage() {
|
||||
<span className="badge badge-warn busy">{busy ? "Идёт конвертация…" : "Нет готовых версий"}</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 className="actions">
|
||||
{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: {
|
||||
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}`}
|
||||
</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 className="actions">
|
||||
{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" && 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 ? (
|
||||
|
||||
@@ -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) {
|
||||
* { 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