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

+3
View File
@@ -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>
);
}
+177 -55
View File
@@ -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>
)}
+50
View File
@@ -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; } }
+1
View File
@@ -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); }
}
+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,
};
}
+1
View File
@@ -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;
+97
View File
@@ -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>
);
}
+39
View File
@@ -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>
);
}
+66
View File
@@ -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))];
}
+11
View File
@@ -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;
}
+9 -4
View File
@@ -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>;
}