diff --git a/backend/app/schemas.py b/backend/app/schemas.py index a130504..2cbf7bd 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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]] = [] diff --git a/backend/app/translation.py b/backend/app/translation.py index 887b3df..9267cce 100644 --- a/backend/app/translation.py +++ b/backend/app/translation.py @@ -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())], diff --git a/backend/tests/test_version_languages.py b/backend/tests/test_version_languages.py new file mode 100644 index 0000000..6d970b8 --- /dev/null +++ b/backend/tests/test_version_languages.py @@ -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": []} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 4417f96..d781c86 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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; diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index 8d5fd36..5bbaacd 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -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() { {busy ? "Идёт конвертация…" : "Нет готовых версий"} )} {playVersion && game.versions.length > 1 && версия «{playVersion.name}»} + { + 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"); +});