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:
1 parent
4e047f3aa0
commit
ac164d1782
20 files changed
+2039
-298
No files matched your search
@@ -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} />Отчёт
|
||||
|
||||
Reference in new issue
Block a user