feat: language picker with flags, paging and follow mode, translation run in the floating window
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:
1 parent
5f6bd4508e
commit
7fa88e4025
82 files changed
+978
-187
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user