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
@@ -18,6 +18,7 @@ import { Icon } from "./ui/Icon";
|
||||
import { useSpatialNav } from "./ui/nav";
|
||||
import { THEME_ICON, THEME_LABEL } from "./ui/ThemeButton";
|
||||
import { UploadProvider } from "./uploads";
|
||||
import { TranslationRunProvider } from "./translator/runner";
|
||||
|
||||
function RequirePerm({ perm, children }: { perm: string; children: JSX.Element }) {
|
||||
const { can } = useAuth();
|
||||
@@ -94,6 +95,7 @@ export function App() {
|
||||
if (me.must_change_password || me.must_change_username) return <ChangeCredentials forced />;
|
||||
|
||||
return (
|
||||
<TranslationRunProvider key={me.id}>
|
||||
<UploadProvider key={me.id}>
|
||||
<Routes>
|
||||
<Route path="/play/:versionId" element={<RequirePerm perm="games.play"><Play /></RequirePerm>} />
|
||||
@@ -140,5 +142,6 @@ export function App() {
|
||||
/>
|
||||
</Routes>
|
||||
</UploadProvider>
|
||||
</TranslationRunProvider>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,12 @@ import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"
|
||||
import { api, uploadRaw, type Version } from "../api";
|
||||
import { EmptyState, ErrorNote, PageHeader, Spinner } from "../ui/Bits";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { LanguagePicker } from "../ui/LanguagePicker";
|
||||
import { Pager } from "../ui/Pager";
|
||||
import type { LangOption } from "../ui/language-catalog";
|
||||
import { useUi } from "../ui/Ui";
|
||||
import { MachineTranslate, type View } from "../translator/MachineTranslate";
|
||||
import { useTranslationRun } from "../translator/runner";
|
||||
import { useTranslatorSession, type Session } from "../translator/session";
|
||||
import { GlossaryEditor, ProgressBar, highlight } from "./TranslationParts";
|
||||
import {
|
||||
@@ -13,6 +17,28 @@ import {
|
||||
} from "./translation-types";
|
||||
import "./translate.css";
|
||||
|
||||
/** The page the automatic run is on: highlighted in the list, and followed when asked. */
|
||||
interface Working {
|
||||
kind: string;
|
||||
/** lines of the page that are still being translated */
|
||||
keys: string[];
|
||||
/** the first line of the page; changes when the run moves on */
|
||||
cursor: string;
|
||||
active: boolean;
|
||||
}
|
||||
const NOT_WORKING: Working = { kind: "", keys: [], cursor: "", active: false };
|
||||
const SIZES = [20, 40, 100, 200];
|
||||
const SIZE_KEY = "pg.translation.pageSize";
|
||||
|
||||
function savedSize(): number {
|
||||
try {
|
||||
const n = Number(localStorage.getItem(SIZE_KEY));
|
||||
return SIZES.includes(n) ? n : PAGE;
|
||||
} catch {
|
||||
return PAGE;
|
||||
}
|
||||
}
|
||||
|
||||
export function TranslationEditor() {
|
||||
const { versionId } = useParams();
|
||||
const [params, setParams] = useSearchParams();
|
||||
@@ -22,6 +48,7 @@ export function TranslationEditor() {
|
||||
const [ov, setOv] = useState<Overview | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [machine, setMachine] = useState(false);
|
||||
const [picking, setPicking] = useState(false);
|
||||
const lang = params.get("lang") ?? "";
|
||||
const tab = (params.get("tab") as Tab) ?? "lines";
|
||||
const session = useTranslatorSession(versionId, version, lang, ov);
|
||||
@@ -33,6 +60,15 @@ export function TranslationEditor() {
|
||||
useEffect(() => {
|
||||
fills.current = {};
|
||||
}, [lang]);
|
||||
// the lines the automatic run is translating right now; `cursor` is the first of the page
|
||||
// the run itself lives above the pages (see runner.tsx), so it goes on when this page is left
|
||||
const run = useTranslationRun();
|
||||
const [follow, setFollow] = useState(true);
|
||||
const working = useMemo<Working>(() => {
|
||||
const t = run.task;
|
||||
if (!t || t.versionId !== versionId || t.lang !== lang || !run.busy) return NOT_WORKING;
|
||||
return { kind: t.kind, keys: t.keys, cursor: t.cursor, active: true };
|
||||
}, [run.task, run.busy, versionId, lang]);
|
||||
const onFill = useCallback((kind: string, results: { key: string; text: string }[]) => {
|
||||
const map = (fills.current[kind] ??= {});
|
||||
for (const r of results) map[r.key] = r.text;
|
||||
@@ -42,6 +78,13 @@ export function TranslationEditor() {
|
||||
setOv((o) => o && { ...o, languages: o.languages.map((l) => l.code === lang ? { ...l, ...s } : l) });
|
||||
}, [lang]);
|
||||
|
||||
// pages the run has saved show up in the open rows at once
|
||||
useEffect(() => run.subscribe((ev) => {
|
||||
if (ev.versionId !== versionId || ev.lang !== lang) return;
|
||||
onFill(ev.kind, ev.results);
|
||||
onStats(ev.stats);
|
||||
}), [run, versionId, lang, onFill, onStats]);
|
||||
|
||||
const setParam = (key: string, value: string) => {
|
||||
const next = new URLSearchParams(params);
|
||||
if (value) next.set(key, value);
|
||||
@@ -70,30 +113,30 @@ export function TranslationEditor() {
|
||||
|
||||
const stats = ov?.languages.find((l) => l.code === lang);
|
||||
|
||||
const addLanguage = async () => {
|
||||
const code = await ui.prompt({
|
||||
title: "Новый язык",
|
||||
text: "Код языка в формате BCP-47: ru, en, de, ja, pt-BR, zh-Hans…",
|
||||
label: "Код языка", confirmLabel: "Добавить",
|
||||
});
|
||||
if (!code) return;
|
||||
const clean = code.trim();
|
||||
if (clean === ov?.sourceLang) {
|
||||
ui.toast("Это исходный язык игры", "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.put(`/api/versions/${versionId}/i18n/${encodeURIComponent(clean)}`, {
|
||||
name: ov?.knownLanguages[clean] ?? null,
|
||||
});
|
||||
await loadOverview();
|
||||
setParam("lang", clean);
|
||||
ui.toast("Язык добавлен", "ok");
|
||||
} catch (e) {
|
||||
ui.toast((e as Error).message, "error");
|
||||
}
|
||||
const addLanguage = (option: LangOption) => {
|
||||
setPicking(false);
|
||||
void (async () => {
|
||||
try {
|
||||
await api.put(`/api/versions/${versionId}/i18n/${encodeURIComponent(option.code)}`, {
|
||||
name: ov?.knownLanguages[option.code] ?? option.native,
|
||||
});
|
||||
await loadOverview();
|
||||
setParam("lang", option.code);
|
||||
ui.toast("Язык добавлен", "ok");
|
||||
} catch (e) {
|
||||
ui.toast((e as Error).message, "error");
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
// languages that cannot be added again, and why
|
||||
const taken = useMemo(() => {
|
||||
const out: Record<string, string> = {};
|
||||
for (const l of ov?.languages ?? []) out[l.code] = "уже добавлен";
|
||||
if (ov?.sourceLang) out[ov.sourceLang] = "язык игры";
|
||||
return out;
|
||||
}, [ov]);
|
||||
|
||||
const publish = async () => {
|
||||
if (!version) return;
|
||||
const ok = await ui.confirm({
|
||||
@@ -147,6 +190,7 @@ export function TranslationEditor() {
|
||||
const draft = version.visibility === "draft";
|
||||
return (
|
||||
<div className="page tr-page">
|
||||
{picking && <LanguagePicker taken={taken} onPick={addLanguage} onCancel={() => setPicking(false)} />}
|
||||
<Link to={`/games/${version.game_id}`} className="back-link"><Icon name="back" size={18} />К игре</Link>
|
||||
<PageHeader
|
||||
title={`Перевод · ${version.name}`}
|
||||
@@ -174,7 +218,7 @@ export function TranslationEditor() {
|
||||
</button>
|
||||
))}
|
||||
{ov.editable && (
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => void addLanguage()}><Icon name="plus" size={16} />Язык</button>
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => setPicking(true)}><Icon name="plus" size={16} />Язык</button>
|
||||
)}
|
||||
</div>
|
||||
{stats && (
|
||||
@@ -214,14 +258,14 @@ export function TranslationEditor() {
|
||||
</div>
|
||||
|
||||
{machine && ov.editable && stats && (
|
||||
<MachineTranslate key={lang} session={session} versionId={versionId!} lang={lang} view={view}
|
||||
onFill={onFill} onStats={onStats} onClose={() => setMachine(false)} />
|
||||
<MachineTranslate key={lang} session={session} versionId={versionId!} lang={lang} view={view} title={session.title}
|
||||
follow={follow} onFollow={setFollow} onClose={() => setMachine(false)} />
|
||||
)}
|
||||
|
||||
{!ov.languages.length ? (
|
||||
<EmptyState icon="globe" title="Переводов пока нет"
|
||||
text={ov.editable ? "Добавьте язык, чтобы начать перевод." : "У этой версии нет переводов."}
|
||||
action={ov.editable ? <button className="btn btn-primary" onClick={() => void addLanguage()}><Icon name="plus" size={18} />Добавить язык</button> : undefined} />
|
||||
action={ov.editable ? <button className="btn btn-primary" onClick={() => setPicking(true)}><Icon name="plus" size={18} />Добавить язык</button> : undefined} />
|
||||
) : stats ? (
|
||||
<>
|
||||
<div className="chips tr-tabs" role="tablist">
|
||||
@@ -231,16 +275,18 @@ export function TranslationEditor() {
|
||||
))}
|
||||
</div>
|
||||
<EntriesView key={`${lang}-${tab}-${params.get("r") ?? ""}`} versionId={versionId!} lang={lang} tab={tab}
|
||||
ov={ov} onStats={onStats} session={session} fills={fills.current} fillTick={fillTick} onView={setView} />
|
||||
ov={ov} onStats={onStats} session={session} fills={fills.current} fillTick={fillTick} onView={setView}
|
||||
working={working} runBusy={run.busy} follow={follow} onFollow={setFollow} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTick, onView }: {
|
||||
function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTick, onView, working, runBusy, follow, onFollow }: {
|
||||
versionId: string; lang: string; tab: Tab; ov: Overview; onStats: (s: Partial<LangStats>) => void;
|
||||
session: Session; fills: Record<string, Record<string, string>>; fillTick: number; onView: (v: View) => void;
|
||||
working: Working; runBusy: boolean; follow: boolean; onFollow: (on: boolean) => void;
|
||||
}) {
|
||||
const ui = useUi();
|
||||
const [state, setState] = useState("todo");
|
||||
@@ -249,13 +295,21 @@ function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTi
|
||||
const [q, setQ] = useState("");
|
||||
const [items, setItems] = useState<Item[] | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [size, setSize] = useState(savedSize);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [reload, setReload] = useState(0);
|
||||
const top = useRef<HTMLDivElement>(null);
|
||||
const [glossary, setGlossary] = useState<GlossaryTerm[]>([]);
|
||||
const [saving, setSaving] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
const pending = useRef<Record<string, { t: string; state: string } | null>>({});
|
||||
const timer = useRef<number>();
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setQ(query), 300);
|
||||
const t = setTimeout(() => {
|
||||
setQ(query);
|
||||
setPage(0);
|
||||
}, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [query]);
|
||||
|
||||
@@ -264,24 +318,34 @@ function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTi
|
||||
.then((m) => setGlossary(m.glossary ?? [])).catch(() => {});
|
||||
}, [versionId, lang]);
|
||||
|
||||
const fetchPage = useCallback(async (offset: number) => {
|
||||
const p = new URLSearchParams({ kind: tab, state, module, q, offset: String(offset), limit: String(PAGE) });
|
||||
return api.get<{ total: number; items: Item[] }>(`/api/versions/${versionId}/i18n/${lang}/entries?${p}`);
|
||||
const listUrl = useCallback((offset: number, limit: number, locate = "") => {
|
||||
const p = new URLSearchParams({ kind: tab, state, module, q, offset: String(offset), limit: String(limit) });
|
||||
if (locate) p.set("locate", locate);
|
||||
return `/api/versions/${versionId}/i18n/${lang}/entries?${p}`;
|
||||
}, [versionId, lang, tab, state, module, q]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === "glossary") return;
|
||||
let cancelled = false;
|
||||
setItems(null);
|
||||
fetchPage(0).then((r) => {
|
||||
setLoading(true);
|
||||
api.get<{ total: number; items: Item[] }>(listUrl(page * size, size)).then((r) => {
|
||||
if (cancelled) return;
|
||||
// the list got shorter while we were on a late page (lines were translated): step back
|
||||
if (!r.items.length && page > 0 && r.total > 0) {
|
||||
setPage(Math.ceil(r.total / size) - 1);
|
||||
return;
|
||||
}
|
||||
setItems(r.items);
|
||||
setTotal(r.total);
|
||||
}).catch((e) => ui.toast(e.message, "error"));
|
||||
setLoading(false);
|
||||
}).catch((e) => {
|
||||
if (!cancelled) setLoading(false);
|
||||
ui.toast(e.message, "error");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fetchPage, tab, ui]);
|
||||
}, [listUrl, page, size, reload, tab, ui]);
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
window.clearTimeout(timer.current);
|
||||
@@ -320,6 +384,46 @@ function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTi
|
||||
// don't lose edits when leaving the page
|
||||
useEffect(() => () => void flush(), [flush]);
|
||||
|
||||
const pages = Math.max(1, Math.ceil(total / size));
|
||||
const goPage = (next: number) => {
|
||||
onFollow(false);
|
||||
void flush().then(() => {
|
||||
setPage(next);
|
||||
top.current?.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
});
|
||||
};
|
||||
const filtered = (change: () => void) => {
|
||||
setPage(0);
|
||||
change();
|
||||
};
|
||||
|
||||
// follow the automatic run: open the page that holds the line it has just started on
|
||||
const cursor = working.kind === tab ? working.cursor : "";
|
||||
useEffect(() => {
|
||||
if (!follow || !cursor) return;
|
||||
let stale = false;
|
||||
void (async () => {
|
||||
await flush();
|
||||
const r = await api.get<{ located: number | null }>(listUrl(0, 1, cursor));
|
||||
if (stale || r.located === null) return;
|
||||
const target = Math.floor(r.located / size);
|
||||
if (target !== page) setPage(target);
|
||||
// lines that were translated have left this list: it has to be read again
|
||||
else if (state === "untranslated" || state === "outdated") setReload((t) => t + 1);
|
||||
})().catch(() => {});
|
||||
return () => {
|
||||
stale = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [follow, cursor]);
|
||||
|
||||
const lit = useMemo(() => new Set(working.kind === tab ? working.keys : []), [working, tab]);
|
||||
useEffect(() => {
|
||||
if (!follow || !cursor || loading) return;
|
||||
document.querySelector(".tr-row.working")?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cursor, follow, page, loading]);
|
||||
|
||||
const update = (key: string, t: string, nextState?: LineState) => {
|
||||
setItems((list) => list?.map((it) => it.key === key ? {
|
||||
...it, translation: t,
|
||||
@@ -331,11 +435,6 @@ function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTi
|
||||
timer.current = window.setTimeout(() => void flush(), 900);
|
||||
};
|
||||
|
||||
const more = async () => {
|
||||
const r = await fetchPage(items?.length ?? 0);
|
||||
setItems((list) => [...(list ?? []), ...r.items]);
|
||||
};
|
||||
|
||||
const terms = useMemo(() => glossary.filter((g) => g.src.trim()), [glossary]);
|
||||
|
||||
if (tab === "glossary") {
|
||||
@@ -351,7 +450,7 @@ function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTi
|
||||
onChange={(e) => setQuery(e.target.value)} aria-label="Поиск" />
|
||||
</div>
|
||||
{tab === "lines" && ov.modules.length > 1 && (
|
||||
<select value={module} onChange={(e) => setModule(e.target.value)} aria-label="Файл скрипта">
|
||||
<select value={module} onChange={(e) => filtered(() => setModule(e.target.value))} aria-label="Файл скрипта">
|
||||
<option value="">Все файлы</option>
|
||||
{ov.modules.map((m) => <option key={m.path} value={m.path}>{m.path.replace(/^script\//, "").replace(/\.json$/, "")} · {m.lines}</option>)}
|
||||
</select>
|
||||
@@ -359,10 +458,16 @@ function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTi
|
||||
<span className={`muted small tr-saving ${saving}`}>
|
||||
{saving === "saving" ? "Сохранение…" : saving === "saved" ? "Сохранено" : saving === "error" ? "Ошибка сохранения" : ""}
|
||||
</span>
|
||||
{working.active && working.kind === tab && (
|
||||
<button className={`btn btn-sm ${follow ? "btn-primary" : ""}`} aria-pressed={follow} onClick={() => onFollow(!follow)}
|
||||
title="Список сам открывает страницу, которую сейчас переводит модель">
|
||||
<Icon name="eye" size={16} />Следить за переводом
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="chips" style={{ marginBottom: "var(--space-4)" }}>
|
||||
{FILTERS.map(([v, label]) => (
|
||||
<button key={v} className={`chip ${state === v ? "on" : ""}`} onClick={() => setState(v)}>{label}</button>
|
||||
<button key={v} className={`chip ${state === v ? "on" : ""}`} onClick={() => filtered(() => setState(v))}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -370,11 +475,28 @@ function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTi
|
||||
<EmptyState icon="check" title={state === "todo" ? "Здесь всё переведено" : "Ничего не найдено"} />
|
||||
) : (
|
||||
<>
|
||||
<div className="muted small" style={{ marginBottom: "var(--space-2)" }}>Найдено: {total}</div>
|
||||
<div className="tr-list">
|
||||
<div className="tr-listbar" ref={top}>
|
||||
<span className="muted small">
|
||||
Найдено: {total} · показаны {page * size + 1}–{Math.min(total, page * size + items.length)}
|
||||
</span>
|
||||
<Pager page={page} pages={pages} onPage={goPage} disabled={loading} />
|
||||
<label className="tr-size muted small">На странице
|
||||
<select value={size} aria-label="Строк на странице"
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
try { localStorage.setItem(SIZE_KEY, String(n)); } catch { /* not remembered */ }
|
||||
// keep the first visible line on screen
|
||||
setPage(Math.floor((page * size) / n));
|
||||
setSize(n);
|
||||
}}>
|
||||
{SIZES.map((n) => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className={`tr-list ${loading ? "loading" : ""}`} aria-busy={loading}>
|
||||
{items.map((it, i) => (
|
||||
<Row key={it.key} item={it} versionId={versionId} lang={lang} editable={ov.editable}
|
||||
canApprove={ov.canApprove} terms={terms} machine={session.ready ? session : null}
|
||||
canApprove={ov.canApprove} terms={terms} machine={session.ready ? session : null} blocked={runBusy} working={lit.has(it.key)}
|
||||
onChange={(t, s) => update(it.key, t, s)} onBlur={() => void flush()}
|
||||
onNext={() => {
|
||||
void flush();
|
||||
@@ -383,19 +505,19 @@ function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTi
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
{items.length < total && (
|
||||
<div className="center" style={{ marginTop: "var(--space-4)" }}>
|
||||
<button className="btn" onClick={() => void more()}>Показать ещё ({total - items.length})</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="tr-pager-bottom"><Pager page={page} pages={pages} onPage={goPage} disabled={loading} /></div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ item, versionId, lang, editable, canApprove, terms, machine, onChange, onBlur, onNext }: {
|
||||
function Row({ item, versionId, lang, editable, canApprove, terms, machine, blocked, working, onChange, onBlur, onNext }: {
|
||||
item: Item; versionId: string; lang: string; editable: boolean; canApprove: boolean; terms: GlossaryTerm[];
|
||||
/** the automatic run is translating this line now */
|
||||
working: boolean;
|
||||
/** an automatic run is going: one line would only queue behind it */
|
||||
blocked: boolean;
|
||||
/** the connected translator: when set, the line can be translated by itself */
|
||||
machine: Session | null;
|
||||
onChange: (t: string, state?: LineState) => void; onBlur: () => void; onNext: () => void;
|
||||
@@ -425,7 +547,7 @@ function Row({ item, versionId, lang, editable, canApprove, terms, machine, onCh
|
||||
}, [item.translation]);
|
||||
|
||||
return (
|
||||
<div className={`tr-row state-${item.state}`}>
|
||||
<div className={`tr-row state-${item.state}${working ? " working" : ""}`}>
|
||||
<div className="tr-src">
|
||||
<div className="tr-meta">
|
||||
{item.who ? <b>{item.who}</b> : item.usage ? <span>{USAGE[item.usage] ?? item.usage}</span> : <span className="muted">рассказчик</span>}
|
||||
@@ -469,8 +591,8 @@ function Row({ item, versionId, lang, editable, canApprove, terms, machine, onCh
|
||||
{editable && (
|
||||
<div className="tr-row-actions">
|
||||
{machine && (
|
||||
<button className="btn btn-sm btn-ghost" disabled={busy} onClick={() => void translateHere()}
|
||||
title="Перевести эту строку нейросетью на вашем компьютере">
|
||||
<button className="btn btn-sm btn-ghost" disabled={busy || blocked} onClick={() => void translateHere()}
|
||||
title={blocked ? "Идёт автоперевод: подождите или остановите его" : "Перевести эту строку нейросетью на вашем компьютере"}>
|
||||
<Icon name="globe" size={14} />{busy ? "Перевожу…" : item.translation ? "Перевести заново" : "Перевести"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -77,3 +77,53 @@
|
||||
.tr-context { flex-direction: column; }
|
||||
.tr-context img { width: 100%; }
|
||||
}
|
||||
|
||||
/* Language picker */
|
||||
.lp-modal { width: min(32rem, 100%); display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.lp-modal h2 { margin: 0; }
|
||||
.lp-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.25rem;
|
||||
max-height: min(22rem, 50vh);
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.lp-option { display: flex; align-items: center; gap: var(--space-3); padding: 0.45rem 0.6rem; border-radius: 6px; cursor: pointer; }
|
||||
.lp-option.on { background: var(--accent-soft); }
|
||||
.lp-option.taken { opacity: 0.5; cursor: not-allowed; }
|
||||
.lp-names { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.lp-flag { width: 24px; height: 16px; object-fit: cover; border-radius: 2px; box-shadow: 0 0 0 1px var(--border); flex-shrink: 0; }
|
||||
.lp-flag-none { display: inline-grid; place-items: center; box-shadow: none; color: var(--muted); }
|
||||
.lp-empty { padding: var(--space-3); }
|
||||
|
||||
/* Paging and following the automatic run */
|
||||
.tr-listbar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; margin-bottom: var(--space-3); }
|
||||
.tr-listbar .tr-size { display: inline-flex; align-items: center; gap: 0.4rem; }
|
||||
.tr-listbar select { height: 2.2rem; }
|
||||
.tr-list.loading { opacity: 0.55; transition: opacity 0.15s; pointer-events: none; }
|
||||
.tr-pager-bottom { display: flex; justify-content: center; margin-top: var(--space-5); }
|
||||
.pager { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; justify-content: center; }
|
||||
.pager-pages { display: flex; align-items: center; gap: 0.25rem; }
|
||||
.pager-page {
|
||||
min-width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
padding: 0 0.5rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-s);
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pager-page:hover:not(:disabled) { background: var(--surface-2); color: var(--text); }
|
||||
.pager-page.on { background: var(--accent-soft); border-color: var(--accent); color: var(--text); font-weight: 600; }
|
||||
.pager-gap { padding: 0 0.2rem; color: var(--muted); }
|
||||
.pager-step { min-width: 2.2rem; justify-content: center; }
|
||||
.pager-jump { display: inline-flex; align-items: center; gap: 0.4rem; margin-left: var(--space-2); }
|
||||
.pager-jump input { width: 4.5rem; height: 2.2rem; text-align: center; }
|
||||
.tr-row.working { border-left-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); animation: tr-working 1.6s ease-in-out infinite; }
|
||||
@keyframes tr-working { 50% { box-shadow: 0 0 0 3px var(--accent-soft); } }
|
||||
@media (prefers-reduced-motion: reduce) { .tr-row.working { animation: none; } }
|
||||
@@ -678,6 +678,7 @@ input[type="file"] { padding: 0.4rem; }
|
||||
.upload-task-heading b { overflow-wrap: anywhere; min-width: 0; }
|
||||
.upload-floating { position: fixed; right: var(--space-4); bottom: var(--space-4); z-index: 50; width: min(360px, calc(100vw - 2rem)); padding: var(--space-4); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-2); max-height: calc(100dvh - var(--nav-h) - 2rem); overflow-y: auto; }
|
||||
.upload-floating .upload-progress { padding: 0; border: 0; box-shadow: none; }
|
||||
.upload-floating > * + * { margin-top: var(--space-3); }
|
||||
@media (max-width: 700px) {
|
||||
.upload-floating { bottom: calc(var(--tabbar-h) + env(safe-area-inset-bottom) + 1rem); max-height: calc(100dvh - var(--nav-h) - var(--tabbar-h) - env(safe-area-inset-bottom) - 2rem); }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -37,6 +37,7 @@ const PATHS = {
|
||||
send: "M22 2 11 13M22 2l-7 20-4-9-9-4 20-7Z",
|
||||
star: "m12 3 2.8 5.7 6.2.9-4.5 4.4 1 6.2L12 17.3 6.5 20.2l1-6.2L3 9.6l6.2-.9L12 3Z",
|
||||
type: "M4 7V4h16v3M9 20h6M12 4v16",
|
||||
eye: "M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12ZM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z",
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof PATHS;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { Icon } from "./Icon";
|
||||
import { BCP47, describeLanguage, languageCatalog, searchLanguages, type LangOption } from "./language-catalog";
|
||||
|
||||
function Flag({ option }: { option: LangOption }) {
|
||||
return option.flag
|
||||
? <img className="lp-flag" src={`/flags/${option.flag}.svg`} alt="" width={24} height={16} loading="lazy" />
|
||||
: <span className="lp-flag lp-flag-none"><Icon name="globe" size={14} /></span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A dialog to choose a language: flags, search by code or by a name in any language, arrow keys.
|
||||
* Any other BCP-47 code can still be typed in.
|
||||
*/
|
||||
export function LanguagePicker({ title = "Новый язык", taken, onPick, onCancel }: {
|
||||
title?: string;
|
||||
/** codes that cannot be chosen, with the reason shown next to them */
|
||||
taken: Record<string, string>;
|
||||
onPick: (option: LangOption) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [text, setText] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const list = useRef<HTMLUListElement>(null);
|
||||
const id = useId();
|
||||
|
||||
const options = useMemo(() => {
|
||||
const found = searchLanguages(languageCatalog(), text);
|
||||
const typed = text.trim();
|
||||
// a code that is not in the catalog: offer it as is
|
||||
if (BCP47.test(typed) && !languageCatalog().some((o) => o.code.toLowerCase() === typed.toLowerCase())) {
|
||||
return [...found, describeLanguage(typed)];
|
||||
}
|
||||
return found;
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => input.current?.focus(), []);
|
||||
useEffect(() => setActive(0), [text]);
|
||||
useEffect(() => {
|
||||
list.current?.children[active]?.scrollIntoView({ block: "nearest" });
|
||||
}, [active]);
|
||||
|
||||
const choose = (option: LangOption | undefined) => {
|
||||
if (option && !taken[option.code]) onPick(option);
|
||||
};
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") setActive((a) => Math.min(a + 1, options.length - 1));
|
||||
else if (e.key === "ArrowUp") setActive((a) => Math.max(a - 1, 0));
|
||||
else if (e.key === "Home" && e.ctrlKey) setActive(0);
|
||||
else if (e.key === "Enter") choose(options[active]);
|
||||
else if (e.key === "Escape") onCancel();
|
||||
else return;
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && onCancel()}>
|
||||
<div className="modal lp-modal" role="dialog" aria-modal="true" aria-label={title}>
|
||||
<h2>{title}</h2>
|
||||
<label className="field">
|
||||
<span>Язык или код</span>
|
||||
<input ref={input} value={text} onChange={(e) => setText(e.target.value)} onKeyDown={onKey}
|
||||
placeholder="Начните вводить: немецкий, deutsch, de…" autoComplete="off" spellCheck={false}
|
||||
role="combobox" aria-expanded="true" aria-controls={`${id}-list`}
|
||||
aria-activedescendant={options[active] ? `${id}-${options[active].code}` : undefined} />
|
||||
</label>
|
||||
<ul className="lp-list" id={`${id}-list`} role="listbox" ref={list}>
|
||||
{options.map((o, i) => {
|
||||
const reason = taken[o.code];
|
||||
return (
|
||||
<li key={o.code} id={`${id}-${o.code}`} role="option" aria-selected={i === active} aria-disabled={!!reason}
|
||||
className={`lp-option ${i === active ? "on" : ""} ${reason ? "taken" : ""}`}
|
||||
onMouseMove={() => setActive(i)} onMouseDown={(e) => e.preventDefault()} onClick={() => choose(o)}>
|
||||
<Flag option={o} />
|
||||
<span className="lp-names">
|
||||
<b>{o.name}</b>
|
||||
{o.native.toLowerCase() !== o.name.toLowerCase() && <span className="muted"> · {o.native}</span>}
|
||||
</span>
|
||||
<span className="muted small">{reason ?? o.code}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{!options.length && (
|
||||
<li className="lp-empty muted">
|
||||
Такого языка нет в списке. Код вводится в формате BCP-47: ru, pt-BR, zh-Hans…
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn" onClick={onCancel}>Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Icon } from "./Icon";
|
||||
import { pageNumbers } from "./paging";
|
||||
|
||||
/** Page buttons with previous/next and a "go to page" field. Pages are counted from 0 here, from 1 on screen. */
|
||||
export function Pager({ page, pages, onPage, disabled }: {
|
||||
page: number; pages: number; onPage: (page: number) => void; disabled?: boolean;
|
||||
}) {
|
||||
const [jump, setJump] = useState("");
|
||||
useEffect(() => setJump(""), [page]);
|
||||
if (pages <= 1) return null;
|
||||
|
||||
const go = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const n = Math.round(Number(jump));
|
||||
if (Number.isFinite(n) && n >= 1) onPage(Math.min(n, pages) - 1);
|
||||
setJump("");
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="pager" aria-label="Страницы">
|
||||
<button className="btn btn-sm pager-step" disabled={disabled || page === 0} onClick={() => onPage(page - 1)}
|
||||
aria-label="Предыдущая страница"><Icon name="chevron" size={16} style={{ transform: "rotate(180deg)" }} /></button>
|
||||
<div className="pager-pages">
|
||||
{pageNumbers(page, pages).map((p, i) => p === null
|
||||
? <span key={`gap${i}`} className="pager-gap" aria-hidden="true">…</span>
|
||||
: <button key={p} className={`pager-page ${p === page ? "on" : ""}`} disabled={disabled}
|
||||
aria-current={p === page ? "page" : undefined} onClick={() => onPage(p)}>{p + 1}</button>)}
|
||||
</div>
|
||||
<button className="btn btn-sm pager-step" disabled={disabled || page >= pages - 1} onClick={() => onPage(page + 1)}
|
||||
aria-label="Следующая страница"><Icon name="chevron" size={16} /></button>
|
||||
<form className="pager-jump" onSubmit={go}>
|
||||
<input type="number" min={1} max={pages} value={jump} placeholder={`${page + 1}`} onChange={(e) => setJump(e.target.value)}
|
||||
aria-label={`Перейти на страницу, всего ${pages}`} />
|
||||
<span className="muted small">из {pages}</span>
|
||||
</form>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Languages offered when adding a translation: BCP-47 code and the country whose flag stands for it.
|
||||
// Names come from the browser (Intl.DisplayNames), so they are right in Russian and in the language itself.
|
||||
|
||||
export interface LangOption {
|
||||
code: string;
|
||||
/** Russian name, e.g. «Немецкий» */
|
||||
name: string;
|
||||
/** the language's own name, e.g. «Deutsch» */
|
||||
native: string;
|
||||
/** lower-case country code of the flag file in /flags, if there is one */
|
||||
flag: string | null;
|
||||
/** everything the search matches against, lower-case */
|
||||
haystack: string;
|
||||
}
|
||||
|
||||
const COUNTRY: Record<string, string> = {
|
||||
en: "gb", ru: "ru", uk: "ua", be: "by", de: "de", fr: "fr", es: "es", "es-419": "mx", pt: "pt", "pt-BR": "br",
|
||||
it: "it", pl: "pl", cs: "cz", sk: "sk", nl: "nl", no: "no", sv: "se", da: "dk", fi: "fi", tr: "tr", el: "gr",
|
||||
hu: "hu", ro: "ro", bg: "bg", sr: "rs", hr: "hr", bs: "ba", sl: "si", lt: "lt", lv: "lv", et: "ee",
|
||||
ja: "jp", ko: "kr", "zh-Hans": "cn", "zh-Hant": "tw", th: "th", vi: "vn", id: "id", ms: "my", fil: "ph",
|
||||
hi: "in", "hi-Latn": "in", bn: "bd", ar: "sa", he: "il", fa: "ir", ka: "ge", hy: "am", az: "az", kk: "kz",
|
||||
uz: "uz", is: "is", ga: "ie", af: "za", sw: "ke", ur: "pk", mn: "mn", sq: "al", mk: "mk", mt: "mt", lb: "lu",
|
||||
ca: "es", gl: "es", eu: "es", ta: "in",
|
||||
};
|
||||
|
||||
function displayName(locale: string, code: string): string {
|
||||
try {
|
||||
return new Intl.DisplayNames([locale], { type: "language" }).of(code) ?? code;
|
||||
} catch {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
function capital(text: string): string {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
export function describeLanguage(code: string): LangOption {
|
||||
const name = capital(displayName("ru", code));
|
||||
const native = capital(displayName(code, code));
|
||||
const english = displayName("en", code);
|
||||
return {
|
||||
code, name, native, flag: COUNTRY[code] ?? null,
|
||||
haystack: [code, name, native, english].join(" ").toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
let cached: LangOption[] | null = null;
|
||||
|
||||
/** All offered languages, sorted by Russian name. */
|
||||
export function languageCatalog(): LangOption[] {
|
||||
cached ??= Object.keys(COUNTRY).map(describeLanguage).sort((a, b) => a.name.localeCompare(b.name, "ru"));
|
||||
return cached;
|
||||
}
|
||||
|
||||
export const BCP47 = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
|
||||
|
||||
/** Options that match what was typed: by code or by a name in Russian, English or the language itself. */
|
||||
export function searchLanguages(options: LangOption[], text: string): LangOption[] {
|
||||
const needle = text.trim().toLowerCase();
|
||||
if (!needle) return options;
|
||||
const starts = (o: LangOption) => o.code.toLowerCase() === needle || o.name.toLowerCase().startsWith(needle)
|
||||
|| o.native.toLowerCase().startsWith(needle);
|
||||
const hits = options.filter((o) => o.haystack.includes(needle));
|
||||
return [...hits.filter(starts), ...hits.filter((o) => !starts(o))];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Page numbers to show: the ends and the neighbourhood of the current page, gaps as null. */
|
||||
export function pageNumbers(page: number, pages: number): (number | null)[] {
|
||||
const wanted = new Set([0, pages - 1, page - 2, page - 1, page, page + 1, page + 2]);
|
||||
const sorted = [...wanted].filter((p) => p >= 0 && p < pages).sort((a, b) => a - b);
|
||||
const out: (number | null)[] = [];
|
||||
sorted.forEach((p, i) => {
|
||||
if (i && p - sorted[i - 1] > 1) out.push(p - sorted[i - 1] === 2 ? p - 1 : null);
|
||||
out.push(p);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { api, ApiError, formatSize, uploadRaw, type Game, type Version } from "./api";
|
||||
import { TranslationProgress, useTranslationRun } from "./translator/runner";
|
||||
import { ErrorNote } from "./ui/Bits";
|
||||
import { Icon } from "./ui/Icon";
|
||||
|
||||
@@ -151,9 +152,13 @@ export function UploadProgress() {
|
||||
function FloatingUploads() {
|
||||
const { pathname } = useLocation();
|
||||
const { tasks } = useUploads();
|
||||
if (pathname.replace(/\/$/, "") === "/games/new" || !tasks.length) return null;
|
||||
return <aside className="upload-floating" aria-label="Фоновые загрузки">
|
||||
<UploadProgress />
|
||||
<Link to="/games/new" className="btn btn-primary">К загрузкам</Link>
|
||||
const { task: translation } = useTranslationRun();
|
||||
// on the upload page the uploads are listed in the page itself
|
||||
const uploads = tasks.length > 0 && pathname.replace(/\/$/, "") !== "/games/new";
|
||||
if (!uploads && !translation) return null;
|
||||
return <aside className="upload-floating" aria-label="Фоновые задачи">
|
||||
{uploads && <UploadProgress />}
|
||||
<TranslationProgress />
|
||||
{uploads && <Link to="/games/new" className="btn btn-primary">К загрузкам</Link>}
|
||||
</aside>;
|
||||
}
|
||||
Reference in new issue
Block a user