feat: language picker with flags, paging and follow mode, translation run in the floating window
CI / frontend (push) Failing after 27s
CI / backend (push) Failing after 49s

Language picker with search and SVG flags, real pagination with locate, one global machine translation run shown next to uploads.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 19:24:20 +03:00
1 parent 5f6bd4508e
commit 7fa88e4025
82 files changed
+978 -187

No files matched your search

+59 -77
View File
@@ -1,15 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api";
import type { LangStats } from "../pages/translation-types";
import { ErrorNote } from "../ui/Bits";
import { Icon } from "../ui/Icon";
import { runTranslation, type Progress, type Source } from "./run";
import { statusText, useTranslationRun, type RunState } from "./runner";
import type { Session } from "./session";
import "./translator.css";
const PAGE = 40;
/** What the list below shows right now: the run walks it in the same order. */
export interface View {
kind: "lines" | "strings" | "glossary";
@@ -17,23 +14,26 @@ export interface View {
q: string;
}
type State = "untranslated" | "outdated";
/** Machine translation of the list on screen through the translator running on this computer. */
export function MachineTranslate({ session, versionId, lang, view, onFill, onStats, onClose }: {
session: Session; versionId: string; lang: string; view: View;
/** a page of translations has just been saved: show them in the rows */
onFill: (kind: "lines" | "strings", results: { key: string; text: string }[]) => void;
onStats: (stats: Partial<LangStats>) => void;
export function MachineTranslate({ session, versionId, lang, title, view, follow, onFollow, onClose }: {
session: Session; versionId: string; lang: string;
/** the game and version, for the card in the floating window */
title: string;
view: View;
/** the list jumps to the page being translated */
follow: boolean;
onFollow: (on: boolean) => void;
onClose: () => void;
}) {
const { client, link, ready, prefs, setPref } = session;
const run = useTranslationRun();
const task = run.task;
const mine = !!task && task.versionId === versionId && task.lang === lang;
const running = run.busy && mine;
const elsewhere = run.busy && !mine;
const [todo, setTodo] = useState<number | null>(null);
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState<Progress | null>(null);
const [preparing, setPreparing] = useState(false);
const [error, setError] = useState("");
const [summary, setSummary] = useState("");
const abort = useRef<AbortController | null>(null);
useEffect(() => {
if (link === "idle") void session.connect();
@@ -41,84 +41,58 @@ export function MachineTranslate({ session, versionId, lang, view, onFill, onSta
}, []);
const listable = view.kind !== "glossary";
const states = useMemo<State[]>(() => (prefs.outdated ? ["untranslated", "outdated"] : ["untranslated"]), [prefs.outdated]);
const states = useMemo<RunState[]>(() => (prefs.outdated ? ["untranslated", "outdated"] : ["untranslated"]), [prefs.outdated]);
const entriesUrl = useCallback((state: State, offset: number, limit: number) =>
const countUrl = useCallback((state: RunState) =>
`/api/versions/${versionId}/i18n/${lang}/entries?${new URLSearchParams({
kind: view.kind, state, module: view.module, q: view.q, offset: String(offset), limit: String(limit) })}`,
kind: view.kind, state, module: view.module, q: view.q, offset: "0", limit: "1" })}`,
[versionId, lang, view.kind, view.module, view.q]);
// how much there is to translate with the current list and options
useEffect(() => {
if (running || !listable) return;
if (run.busy || !listable) return;
let stale = false;
Promise.all(states.map((s) => api.get<{ total: number }>(entriesUrl(s, 0, 1))))
Promise.all(states.map((s) => api.get<{ total: number }>(countUrl(s))))
.then((r) => !stale && setTodo(r.reduce((n, x) => n + x.total, 0))).catch(() => !stale && setTodo(null));
return () => {
stale = true;
};
}, [states, entriesUrl, running, listable, summary]);
useEffect(() => () => abort.current?.abort(), []);
}, [states, countUrl, run.busy, listable, task?.status]);
const start = async () => {
if (view.kind === "glossary") return;
const kind = view.kind;
setError("");
setSummary("");
setProgress(null);
const ctl = new AbortController();
abort.current = ctl;
setRunning(true);
const base = { total: 0, done: 0, failed: 0 };
let last: Progress = { ...base, lastError: "", model: null };
setPreparing(true);
try {
const projectId = await session.project();
for (const state of states) {
const outcome = await runTranslation({
client, projectId, batchSize: prefs.batch, pageSize: PAGE, signal: ctl.signal,
fetchPage: (skip, limit) => api.get<{ total: number; items: Source[] }>(entriesUrl(state, skip, limit)),
save: async (results) => {
const changes = Object.fromEntries(results.map((r) => [r.key, { t: r.text, state: "machine" }]));
const stats = await api.put<Partial<LangStats>>(`/api/versions/${versionId}/i18n/${lang}`,
kind === "strings" ? { strings: changes } : { lines: changes });
onFill(kind, results);
onStats(stats);
},
onProgress: (p) => {
last = { ...p, total: base.total + p.total, done: base.done + p.done, failed: base.failed + p.failed };
setProgress(last);
},
});
Object.assign(base, { total: last.total, done: last.done, failed: last.failed });
if (outcome === "stopped") break;
}
setSummary(ctl.signal.aborted ? `Остановлено. Переведено строк: ${last.done}.`
: `Готово. Переведено строк: ${last.done}${last.failed ? `, не удалось: ${last.failed}` : ""}.`);
const started = run.start({
client, projectId, versionId, lang, title, kind: view.kind, module: view.module, q: view.q,
states, batch: prefs.batch,
});
if (!started) setError("Другой перевод уже идёт: дождитесь его или остановите.");
} catch (e) {
setError((e as Error).message);
} finally {
setRunning(false);
abort.current = null;
setPreparing(false);
}
};
const stop = () => abort.current?.abort();
const health = link !== "idle" && link !== "checking" && link.state === "ok" ? link.health : null;
const percent = progress && progress.total ? Math.min(100, ((progress.done + progress.failed) / progress.total) * 100) : 0;
const scope = view.kind === "strings" ? "имена, меню и интерфейс" : "реплики";
const progress = mine && task ? task.progress : null;
const percent = progress && progress.total ? Math.min(100, ((progress.done + progress.failed) / progress.total) * 100) : 0;
const locked = running || elsewhere;
return (
<section className="card mt-panel" aria-label="Машинный перевод">
<div className="mt-head">
<h2>Машинный перевод</h2>
<button className="btn btn-sm btn-ghost" onClick={onClose} disabled={running} aria-label="Закрыть"><Icon name="x" size={16} /></button>
<button className="btn btn-sm btn-ghost" onClick={onClose} aria-label="Закрыть"><Icon name="x" size={16} /></button>
</div>
<p className="muted small">
Перевод выполняет программа на вашем компьютере, сайт только получает готовые строки с пометкой
«Машинный». <Link to="/translator">Как установить и настроить</Link>. Отдельную строку можно перевести
кнопкой «Перевести» рядом с ней.
кнопкой «Перевести» рядом с ней. Перевод идёт в фоне: ход виден в окне справа внизу, можно переходить по сайту.
</p>
{(link === "idle" || link === "checking") && <p className="muted small">Ищу переводчик на {client.base}…</p>}
@@ -139,18 +113,27 @@ export function MachineTranslate({ session, versionId, lang, view, onFill, onSta
)}
{health?.startup_errors[prefs.provider] && <ErrorNote>{health.startup_errors[prefs.provider]}</ErrorNote>}
{elsewhere && task && (
<div className="note note-info">
<Icon name="info" size={18} />
<span>Сейчас идёт другой перевод: «{task.title}» ({task.lang}). Одновременно работает только один.
Дождитесь его окончания или остановите.{" "}
<button className="btn btn-sm" onClick={run.stop} disabled={task.status === "stopping"}>Остановить</button></span>
</div>
)}
{ready && (
<>
<div className="form-row">
<label className="field">
<span>Источник</span>
<select value={prefs.provider} disabled={running} onChange={(e) => { setPref("provider", e.target.value); setPref("model", ""); }}>
<select value={prefs.provider} disabled={locked} onChange={(e) => { setPref("provider", e.target.value); setPref("model", ""); }}>
{session.providers.map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</label>
<label className="field">
<span>Модель</span>
<select value={prefs.model} disabled={running} onChange={(e) => setPref("model", e.target.value)}>
<select value={prefs.model} disabled={locked} onChange={(e) => setPref("model", e.target.value)}>
<option value="">Как в настройках переводчика</option>
{(session.models ?? []).map((m) => <option key={m} value={m}>{m}</option>)}
{prefs.model && !(session.models ?? []).includes(prefs.model) && <option value={prefs.model}>{prefs.model}</option>}
@@ -161,7 +144,7 @@ export function MachineTranslate({ session, versionId, lang, view, onFill, onSta
</label>
<label className="field">
<span>Строк за запрос</span>
<input type="number" min={1} max={50} value={prefs.batch} disabled={running}
<input type="number" min={1} max={50} value={prefs.batch} disabled={locked}
onChange={(e) => setPref("batch", Math.max(1, Math.min(50, Number(e.target.value) || 10)))} />
<small className="hint">Меньше — надёжнее для слабых моделей.</small>
</label>
@@ -169,16 +152,18 @@ export function MachineTranslate({ session, versionId, lang, view, onFill, onSta
<label className="field">
<span>Пожелания к переводу</span>
<textarea rows={2} value={session.instructions} disabled={running} maxLength={5000}
<textarea rows={2} value={session.instructions} disabled={locked} maxLength={5000}
placeholder="Например: обращение на «ты», сохраняй грубость оригинала. Запоминается для этой игры."
onChange={(e) => session.setInstructions(e.target.value)} />
<small className="hint">Имена и термины берутся из вкладки «Глоссарий».</small>
</label>
<div className="mt-options">
<label className="check" title="Описание и жанры помогают выбрать тон и термины"><input type="checkbox" checked={prefs.about} disabled={running}
<label className="check" title="Список сам открывает страницу, которую сейчас переводит модель">
<input type="checkbox" checked={follow} onChange={(e) => onFollow(e.target.checked)} /> Следить за переводом в списке</label>
<label className="check" title="Описание и жанры помогают выбрать тон и термины"><input type="checkbox" checked={prefs.about} disabled={locked}
onChange={(e) => setPref("about", e.target.checked)} /> Передавать описание и жанры игры как контекст</label>
<label className="check"><input type="checkbox" checked={prefs.outdated} disabled={running}
<label className="check"><input type="checkbox" checked={prefs.outdated} disabled={locked}
onChange={(e) => setPref("outdated", e.target.checked)} /> Обновить устаревшие переводы</label>
</div>
@@ -191,28 +176,25 @@ export function MachineTranslate({ session, versionId, lang, view, onFill, onSta
<div className="actions">
{running ? (
<button className="btn" onClick={stop}><Icon name="x" size={18} />Остановить</button>
<button className="btn" onClick={run.stop} disabled={task?.status === "stopping"}><Icon name="x" size={18} />Остановить</button>
) : (
<button className="btn btn-primary" onClick={() => void start()} disabled={!listable || !todo || !prefs.provider}>
<Icon name="play" size={18} />Перевести{todo ? ` · ${todo}` : ""}
<button className="btn btn-primary" onClick={() => void start()}
disabled={!listable || !todo || !prefs.provider || elsewhere || preparing}>
<Icon name="play" size={18} />{preparing ? "Готовлю…" : `Перевести${todo ? ` · ${todo}` : ""}`}
</button>
)}
{listable && todo === 0 && !running && <span className="muted small">Здесь всё уже переведено.</span>}
{listable && todo === 0 && !run.busy && <span className="muted small">Здесь всё уже переведено.</span>}
</div>
</>
)}
{progress && (running || summary) && (
{mine && task && progress && (
<div className="mt-progress" aria-live="polite">
<div className="progress"><div style={{ width: `${percent}%` }} /></div>
<span className="muted small">
Переведено {progress.done} из {progress.total}{progress.failed ? ` · ошибок ${progress.failed}` : ""}
{progress.model ? ` · модель: ${progress.model}` : ""}
</span>
<span className="muted small">{statusText(task)}{progress.model ? ` · модель: ${progress.model}` : ""}</span>
</div>
)}
{progress?.lastError && progress.failed > 0 && <p className="muted small">Последняя ошибка: {progress.lastError}</p>}
{summary && <div className="note note-ok"><Icon name="check" size={18} /><span>{summary}</span></div>}
{mine && task?.error && <ErrorNote>{task.error}</ErrorNote>}
{error && <ErrorNote>{error}</ErrorNote>}
</section>
);
+3
View File
@@ -19,6 +19,8 @@ export interface RunOptions {
/** the next `pageSize` items still to translate, skipping the first `skip` (the ones that failed) */
fetchPage: (skip: number, limit: number) => Promise<{ total: number; items: Source[] }>;
save: (results: { key: string; text: string }[]) => Promise<void>;
/** a page is about to be sent to the model: these are the lines being translated now */
onPage?: (items: Source[]) => void;
onProgress: (p: Progress) => void;
pollMs?: number;
}
@@ -82,6 +84,7 @@ export async function runTranslation(opts: RunOptions): Promise<Outcome> {
skip += page.items.length - items.length;
if (!items.length) continue;
opts.onPage?.(items);
let job = await opts.client.createJob(
opts.projectId, items.map((it, i) => ({ id: String(i), text: it.source })), opts.batchSize);
job = await waitForJob(opts.client, job, opts.signal, opts.pollMs);
+209
View File
@@ -0,0 +1,209 @@
// The one machine translation run of this browser. It lives above the pages, so it goes on while
// the user moves around the site, and its card sits in the same floating window as the uploads.
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Link } from "react-router-dom";
import { api } from "../api";
import type { LangStats } from "../pages/translation-types";
import { ErrorNote } from "../ui/Bits";
import { Icon } from "../ui/Icon";
import type { TranslatorClient } from "./client";
import { runTranslation, type Progress, type Source } from "./run";
const PAGE = 40;
export type RunState = "untranslated" | "outdated";
export interface RunSpec {
client: TranslatorClient;
projectId: string;
versionId: string;
lang: string;
/** shown in the floating card: the game and version being translated */
title: string;
kind: "lines" | "strings";
/** the list the run walks, as the editor shows it */
module: string;
q: string;
states: RunState[];
batch: number;
}
export interface TranslationTask {
id: number;
versionId: string;
lang: string;
title: string;
kind: "lines" | "strings";
status: "running" | "stopping" | "done" | "stopped" | "failed";
progress: Progress;
/** lines of the page being translated that are not saved yet */
keys: string[];
/** the first line of that page: moves when the run moves on */
cursor: string;
error: string;
}
/** What the open editor needs to show a page of results at once. */
export interface FillEvent {
versionId: string;
lang: string;
kind: "lines" | "strings";
results: { key: string; text: string }[];
stats: Partial<LangStats>;
}
interface RunApi {
task: TranslationTask | null;
/** a run is going: no other can start */
busy: boolean;
start: (spec: RunSpec) => boolean;
stop: () => void;
dismiss: () => void;
subscribe: (listener: (event: FillEvent) => void) => () => void;
}
const RunContext = createContext<RunApi | null>(null);
const EMPTY: Progress = { total: 0, done: 0, failed: 0, lastError: "", model: null };
export function TranslationRunProvider({ children }: { children: ReactNode }) {
const [task, setTask] = useState<TranslationTask | null>(null);
const control = useRef<AbortController | null>(null);
const listeners = useRef(new Set<(event: FillEvent) => void>());
const sequence = useRef(0);
const busyRef = useRef(false);
const patch = useCallback((id: number, change: Partial<TranslationTask> | ((t: TranslationTask) => Partial<TranslationTask>)) => {
setTask((t) => (t && t.id === id ? { ...t, ...(typeof change === "function" ? change(t) : change) } : t));
}, []);
const start = useCallback((spec: RunSpec) => {
if (busyRef.current) return false;
busyRef.current = true;
const id = ++sequence.current;
const ctl = new AbortController();
control.current = ctl;
setTask({ id, versionId: spec.versionId, lang: spec.lang, title: spec.title, kind: spec.kind, status: "running",
progress: EMPTY, keys: [], cursor: "", error: "" });
const url = (state: RunState, offset: number, limit: number) =>
`/api/versions/${spec.versionId}/i18n/${spec.lang}/entries?${new URLSearchParams({
kind: spec.kind, state, module: spec.module, q: spec.q, offset: String(offset), limit: String(limit) })}`;
void (async () => {
const base = { total: 0, done: 0, failed: 0 };
let last: Progress = EMPTY;
try {
for (const state of spec.states) {
const outcome = await runTranslation({
client: spec.client, projectId: spec.projectId, batchSize: spec.batch, pageSize: PAGE, signal: ctl.signal,
fetchPage: (skip, limit) => api.get<{ total: number; items: Source[] }>(url(state, skip, limit)),
onPage: (items) => patch(id, { keys: items.map((it) => it.key), cursor: items[0]?.key ?? "" }),
save: async (results) => {
const changes = Object.fromEntries(results.map((r) => [r.key, { t: r.text, state: "machine" }]));
const stats = await api.put<Partial<LangStats>>(`/api/versions/${spec.versionId}/i18n/${spec.lang}`,
spec.kind === "strings" ? { strings: changes } : { lines: changes });
const done = new Set(results.map((r) => r.key));
patch(id, (t) => ({ keys: t.keys.filter((k) => !done.has(k)) }));
for (const listener of listeners.current) {
listener({ versionId: spec.versionId, lang: spec.lang, kind: spec.kind, results, stats });
}
},
onProgress: (p) => {
last = { ...p, total: base.total + p.total, done: base.done + p.done, failed: base.failed + p.failed };
patch(id, { progress: last });
},
});
Object.assign(base, { total: last.total, done: last.done, failed: last.failed });
if (outcome === "stopped") break;
}
patch(id, { status: ctl.signal.aborted ? "stopped" : "done", keys: [] });
} catch (e) {
patch(id, { status: "failed", error: (e as Error).message, keys: [] });
} finally {
busyRef.current = false;
control.current = null;
}
})();
return true;
}, [patch]);
const stop = useCallback(() => {
control.current?.abort();
setTask((t) => (t && t.status === "running" ? { ...t, status: "stopping" } : t));
}, []);
const dismiss = useCallback(() => setTask((t) => (t && (t.status === "running" || t.status === "stopping") ? t : null)), []);
const subscribe = useCallback((listener: (event: FillEvent) => void) => {
listeners.current.add(listener);
return () => void listeners.current.delete(listener);
}, []);
const busy = task?.status === "running" || task?.status === "stopping";
// closing the tab would end the run: ask first
useEffect(() => {
if (!busy) return;
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [busy]);
const value = useMemo(() => ({ task, busy: !!busy, start, stop, dismiss, subscribe }), [task, busy, start, stop, dismiss, subscribe]);
return <RunContext.Provider value={value}>{children}</RunContext.Provider>;
}
export function useTranslationRun(): RunApi {
const ctx = useContext(RunContext);
if (!ctx) throw new Error("TranslationRunProvider is missing");
return ctx;
}
export function statusText(task: TranslationTask): string {
const p = task.progress;
const count = `Переведено ${p.done} из ${p.total}${p.failed ? ` · ошибок ${p.failed}` : ""}`;
switch (task.status) {
case "running": return p.total ? count : "Готовлю перевод…";
case "stopping": return "Останавливаю…";
case "stopped": return `Остановлено. ${count}`;
case "done": return `Готово. ${count}`;
default: return "Перевод прервался";
}
}
/** The card of the run in the floating window. */
export function TranslationProgress() {
const { task, stop, dismiss } = useTranslationRun();
if (!task) return null;
const { progress: p } = task;
const pct = p.total ? Math.min(100, Math.round(((p.done + p.failed) / p.total) * 100)) : 0;
const active = task.status === "running" || task.status === "stopping";
const text = statusText(task);
return (
<section className="upload-progress card" aria-label="Машинный перевод">
<h2>Машинный перевод</h2>
<div className="upload-task">
<div className="upload-task-heading">
<b>{task.title}</b>
{!active && <button className="btn btn-ghost btn-icon" onClick={dismiss} aria-label="Убрать"><Icon name="x" size={16} /></button>}
</div>
<div className="muted small">{task.lang}{p.model ? ` · модель: ${p.model}` : ""}</div>
{task.status !== "failed" && (
<div className="progress" role="progressbar" aria-label="Машинный перевод" aria-valuemin={0} aria-valuemax={100}
aria-valuenow={p.total ? pct : undefined} aria-valuetext={text}>
<div style={{ width: `${pct}%`, opacity: p.total ? 1 : 0.4 }} />
</div>
)}
<p className="small" role="status">{text}</p>
{task.error && <ErrorNote>{task.error}</ErrorNote>}
{p.failed > 0 && p.lastError && <p className="muted small">Последняя ошибка: {p.lastError}</p>}
<div className="actions">
{active && <button className="btn btn-sm" disabled={task.status === "stopping"} onClick={stop}><Icon name="x" size={16} />Остановить</button>}
<Link to={`/versions/${task.versionId}/translate?lang=${encodeURIComponent(task.lang)}${task.kind === "strings" ? "&tab=strings" : ""}`}
className="small">Открыть редактор</Link>
</div>
</div>
</section>
);
}
+12 -1
View File
@@ -58,6 +58,8 @@ export interface Session {
setPref: <K extends keyof Prefs>(key: K, value: Prefs[K]) => void;
instructions: string;
setInstructions: (value: string) => void;
/** the game and version, as the floating card names the run */
title: string;
/** creates or updates the translator project of this game and language, returns its id */
project: () => Promise<string>;
/** translates one line; throws with the reason when the model could not */
@@ -74,6 +76,7 @@ export function useTranslatorSession(versionId: string | undefined, version: Ver
const [instructions, setInstr] = useState("");
const game = useRef<Game | null>(null);
const gameId = version?.game_id;
const [gameTitle, setGameTitle] = useState("");
const setPref = useCallback(<K extends keyof Prefs>(key: K, value: Prefs[K]) => {
setPrefs((p) => {
@@ -104,6 +107,14 @@ export function useTranslatorSession(versionId: string | undefined, version: Ver
if (read(USED_KEY)) void connect();
}, [connect]);
useEffect(() => {
if (gameId === undefined) return;
api.get<Game>(`/api/games/${gameId}`).then((g) => {
game.current = g;
setGameTitle(g.title);
}).catch(() => {});
}, [gameId]);
useEffect(() => {
if (gameId !== undefined) setInstr(read(`pg.translator.instructions.${gameId}`));
}, [gameId]);
@@ -157,6 +168,6 @@ export function useTranslatorSession(versionId: string | undefined, version: Ver
}, [client, project]);
return {
client, link, ready, connect, providers, models, prefs, setPref, instructions, setInstructions, project, translateOne,
client, link, ready, connect, title: [gameTitle, version?.name].filter(Boolean).join(" · "),providers, models, prefs, setPref, instructions, setInstructions, project, translateOne,
};
}