Localization stages 2-3: drafts, publishing, translation editor

- games.main_version_id, version visibility (draft/published) and kind
  (original/translation/mod), version_history; migration 0002
- permissions translations.edit and versions.publish
- "Translate" creates a translation draft; publishing merges the changed
  languages into the main version in place (players keep their saves), the
  previous manifest goes to the history and can be restored
- translation API: overview with per-state stats, entries with filters,
  search and scene context, save, glossary, locale font, PO/CSV export/import
- translation editor page: languages and progress, lines and strings,
  filters, autosave, approve/outdated/fuzzy actions, glossary highlighting,
  "play from here" with the scene restored
- player: ?lang= and ?at= from the editor, per-locale font

(models.py and permissions.py for this landed earlier in 9ee3e1f)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-25 03:56:19 +03:00
1 parent 4e047f3aa0
commit ac164d1782
20 files changed
+2039 -298

No files matched your search

+66 -3
View File
@@ -80,6 +80,38 @@ export function GamePage() {
if (name) void run(() => api.post(`/api/versions/${v.id}/fork`, { name }), "Форк создан");
};
const translate = async (v: Version) => {
const name = await ui.prompt({
title: "Новый перевод",
text: "Создаётся ваш черновик — копия версии, в которой можно переводить. Игроки увидят перевод после публикации.",
label: "Название черновика", initial: `Перевод (${me?.username})`, confirmLabel: "Начать перевод",
});
if (!name) return;
try {
const fork = await api.post<Version>(`/api/versions/${v.id}/fork`, { name, kind: "translation" });
navigate(`/versions/${fork.id}/translate`);
} catch (e) {
ui.toast((e as Error).message, "error");
}
};
const publish = async (v: Version) => {
const translationDraft = v.kind === "translation";
const ok = await ui.confirm({
title: `Опубликовать «${v.name}»?`,
text: translationDraft
? "Изменённые языки будут влиты в главную версию. Сохранения игроков сохранятся, прежнее состояние останется в истории."
: "Версию увидят все игроки.",
confirmLabel: "Опубликовать",
});
if (ok) void run(() => api.post(`/api/versions/${v.id}/publish`, {}), "Опубликовано");
};
const makeMain = async (v: Version) => {
if (await ui.confirm({ title: `Сделать «${v.name}» главной версией?`, text: "Кнопка «Играть» будет открывать её.", confirmLabel: "Сделать главной" }))
void run(() => api.post(`/api/versions/${v.id}/make-main`, {}), "Главная версия изменена");
};
const uploadCover = (file: File) =>
run(() => uploadRaw("PUT", `/api/games/${game.id}/cover`, file, () => {}), "Обложка обновлена");
@@ -111,7 +143,8 @@ export function GamePage() {
const ready = game.versions.filter((v) => v.status === "ready");
const lastId = getRecent().find((r) => r.gameId === game.id)?.versionId;
const playVersion = ready.find((v) => v.id === lastId) ?? ready.find((v) => v.parent_id === null) ?? ready[0];
const mainVersion = ready.find((v) => v.id === game.main_version_id);
const playVersion = ready.find((v) => v.id === lastId) ?? mainVersion ?? ready.find((v) => v.parent_id === null) ?? ready[0];
const byParent = new Map<number | null, Version[]>();
for (const v of game.versions) {
@@ -120,8 +153,10 @@ export function GamePage() {
}
const renderTree = (parent: number | null, depth: number): JSX.Element[] =>
(byParent.get(parent) ?? []).flatMap((v) => [
<VersionRow key={v.id} v={v} depth={depth} current={v.id === playVersion?.id}
<VersionRow key={v.id} v={v} depth={depth} current={v.id === playVersion?.id} isMain={v.id === game.main_version_id}
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)}
onRetry={() => void run(() => api.post(`/api/versions/${v.id}/retry`))}
onDelete={() => void removeVersion(v)} />,
@@ -188,9 +223,13 @@ export function GamePage() {
);
}
const KIND: Record<Version["kind"], string> = { original: "", translation: "Перевод", mod: "Мод" };
function VersionRow(props: {
v: Version; depth: number; current: boolean; canManage: boolean; canFork: boolean;
v: Version; depth: number; current: boolean; isMain: boolean; canManage: boolean; canFork: boolean;
canTranslate: boolean; canPublish: boolean;
onPlay: () => void; onFork: () => void; onRetry: () => void; onDelete: () => void;
onTranslate: () => void; onPublish: () => void; onMakeMain: () => void;
}) {
const { v } = props;
const [report, setReport] = useState<Version["report"] | undefined>(undefined);
@@ -206,16 +245,40 @@ function VersionRow(props: {
<div className="version-title">
{props.depth > 0 && <span className="fork-mark" title="Форк"><Icon name="fork" size={16} /></span>}
<b>{v.name}</b>
{props.isMain && <span className="badge badge-ok" title="Её открывает кнопка «Играть»">главная</span>}
{KIND[v.kind] && <span className="badge badge-plain">{KIND[v.kind]}</span>}
{v.visibility === "draft" && <span className="badge badge-warn">черновик</span>}
<StatusBadge status={v.status} />
</div>
<div className="muted small version-meta">
{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" && props.canFork && <button className="btn btn-sm" onClick={props.onFork}><Icon name="fork" size={16} />Форк</button>}
{v.status === "ready" && props.canTranslate && v.kind === "translation" && v.visibility === "draft" && props.canManage ? (
<Link className="btn btn-sm" to={`/versions/${v.id}/translate`}><Icon name="globe" size={16} />Редактор перевода</Link>
) : v.status === "ready" && props.canTranslate && v.visibility === "published" && (
<button className="btn btn-sm" onClick={props.onTranslate}><Icon name="globe" size={16} />Перевести</button>
)}
{v.status === "ready" && props.canPublish && v.visibility === "draft" && (
<button className="btn btn-sm btn-primary" onClick={props.onPublish}><Icon name="send" size={16} />Опубликовать</button>
)}
{v.status === "ready" && props.canPublish && !props.isMain && v.visibility === "published" && v.kind !== "translation" && (
<button className="btn btn-sm btn-ghost" onClick={props.onMakeMain} title="Сделать главной"><Icon name="star" size={16} />Главная</button>
)}
{(v.status === "ready" || v.status === "failed") && (
<button className="btn btn-sm btn-ghost" onClick={() => void toggleReport()} aria-expanded={report !== undefined}>
<Icon name="file" size={16} />Отчёт