From 7fa88e40259f8c3b645539d3b163d34513f20673 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:24:20 +0300 Subject: [PATCH] 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 --- backend/app/routers/i18n.py | 55 +---- backend/app/translation.py | 62 +++++ backend/tests/test_translation.py | 16 ++ docs/LOCALIZATION.md | 11 + frontend/public/flags/LICENSE.txt | 22 ++ frontend/public/flags/al.svg | 1 + frontend/public/flags/am.svg | 1 + frontend/public/flags/az.svg | 1 + frontend/public/flags/ba.svg | 1 + frontend/public/flags/bd.svg | 1 + frontend/public/flags/bg.svg | 1 + frontend/public/flags/br.svg | 1 + frontend/public/flags/by.svg | 1 + frontend/public/flags/cn.svg | 1 + frontend/public/flags/cz.svg | 1 + frontend/public/flags/de.svg | 1 + frontend/public/flags/dk.svg | 1 + frontend/public/flags/ee.svg | 1 + frontend/public/flags/es.svg | 1 + frontend/public/flags/fi.svg | 1 + frontend/public/flags/fr.svg | 1 + frontend/public/flags/gb.svg | 1 + frontend/public/flags/ge.svg | 1 + frontend/public/flags/gr.svg | 1 + frontend/public/flags/hr.svg | 1 + frontend/public/flags/hu.svg | 1 + frontend/public/flags/id.svg | 1 + frontend/public/flags/ie.svg | 1 + frontend/public/flags/il.svg | 1 + frontend/public/flags/in.svg | 1 + frontend/public/flags/ir.svg | 1 + frontend/public/flags/is.svg | 1 + frontend/public/flags/it.svg | 1 + frontend/public/flags/jp.svg | 1 + frontend/public/flags/ke.svg | 1 + frontend/public/flags/kr.svg | 1 + frontend/public/flags/kz.svg | 1 + frontend/public/flags/lt.svg | 1 + frontend/public/flags/lu.svg | 1 + frontend/public/flags/lv.svg | 1 + frontend/public/flags/mk.svg | 1 + frontend/public/flags/mn.svg | 1 + frontend/public/flags/mt.svg | 1 + frontend/public/flags/mx.svg | 1 + frontend/public/flags/my.svg | 1 + frontend/public/flags/nl.svg | 1 + frontend/public/flags/no.svg | 1 + frontend/public/flags/ph.svg | 1 + frontend/public/flags/pk.svg | 1 + frontend/public/flags/pl.svg | 1 + frontend/public/flags/pt.svg | 1 + frontend/public/flags/ro.svg | 1 + frontend/public/flags/rs.svg | 1 + frontend/public/flags/ru.svg | 1 + frontend/public/flags/sa.svg | 1 + frontend/public/flags/se.svg | 1 + frontend/public/flags/si.svg | 1 + frontend/public/flags/sk.svg | 1 + frontend/public/flags/th.svg | 1 + frontend/public/flags/tr.svg | 1 + frontend/public/flags/tw.svg | 1 + frontend/public/flags/ua.svg | 1 + frontend/public/flags/us.svg | 1 + frontend/public/flags/uz.svg | 1 + frontend/public/flags/vn.svg | 1 + frontend/public/flags/za.svg | 1 + frontend/src/App.tsx | 3 + frontend/src/pages/TranslationEditor.tsx | 232 ++++++++++++++----- frontend/src/pages/translate.css | 50 ++++ frontend/src/styles.css | 1 + frontend/src/translator/MachineTranslate.tsx | 136 +++++------ frontend/src/translator/run.ts | 3 + frontend/src/translator/runner.tsx | 209 +++++++++++++++++ frontend/src/translator/session.ts | 13 +- frontend/src/ui/Icon.tsx | 1 + frontend/src/ui/LanguagePicker.tsx | 97 ++++++++ frontend/src/ui/Pager.tsx | 39 ++++ frontend/src/ui/language-catalog.ts | 66 ++++++ frontend/src/ui/paging.ts | 11 + frontend/src/uploads.tsx | 13 +- frontend/tests/language-catalog.test.mjs | 39 ++++ frontend/tests/paging.test.mjs | 25 ++ 82 files changed, 978 insertions(+), 187 deletions(-) create mode 100644 frontend/public/flags/LICENSE.txt create mode 100644 frontend/public/flags/al.svg create mode 100644 frontend/public/flags/am.svg create mode 100644 frontend/public/flags/az.svg create mode 100644 frontend/public/flags/ba.svg create mode 100644 frontend/public/flags/bd.svg create mode 100644 frontend/public/flags/bg.svg create mode 100644 frontend/public/flags/br.svg create mode 100644 frontend/public/flags/by.svg create mode 100644 frontend/public/flags/cn.svg create mode 100644 frontend/public/flags/cz.svg create mode 100644 frontend/public/flags/de.svg create mode 100644 frontend/public/flags/dk.svg create mode 100644 frontend/public/flags/ee.svg create mode 100644 frontend/public/flags/es.svg create mode 100644 frontend/public/flags/fi.svg create mode 100644 frontend/public/flags/fr.svg create mode 100644 frontend/public/flags/gb.svg create mode 100644 frontend/public/flags/ge.svg create mode 100644 frontend/public/flags/gr.svg create mode 100644 frontend/public/flags/hr.svg create mode 100644 frontend/public/flags/hu.svg create mode 100644 frontend/public/flags/id.svg create mode 100644 frontend/public/flags/ie.svg create mode 100644 frontend/public/flags/il.svg create mode 100644 frontend/public/flags/in.svg create mode 100644 frontend/public/flags/ir.svg create mode 100644 frontend/public/flags/is.svg create mode 100644 frontend/public/flags/it.svg create mode 100644 frontend/public/flags/jp.svg create mode 100644 frontend/public/flags/ke.svg create mode 100644 frontend/public/flags/kr.svg create mode 100644 frontend/public/flags/kz.svg create mode 100644 frontend/public/flags/lt.svg create mode 100644 frontend/public/flags/lu.svg create mode 100644 frontend/public/flags/lv.svg create mode 100644 frontend/public/flags/mk.svg create mode 100644 frontend/public/flags/mn.svg create mode 100644 frontend/public/flags/mt.svg create mode 100644 frontend/public/flags/mx.svg create mode 100644 frontend/public/flags/my.svg create mode 100644 frontend/public/flags/nl.svg create mode 100644 frontend/public/flags/no.svg create mode 100644 frontend/public/flags/ph.svg create mode 100644 frontend/public/flags/pk.svg create mode 100644 frontend/public/flags/pl.svg create mode 100644 frontend/public/flags/pt.svg create mode 100644 frontend/public/flags/ro.svg create mode 100644 frontend/public/flags/rs.svg create mode 100644 frontend/public/flags/ru.svg create mode 100644 frontend/public/flags/sa.svg create mode 100644 frontend/public/flags/se.svg create mode 100644 frontend/public/flags/si.svg create mode 100644 frontend/public/flags/sk.svg create mode 100644 frontend/public/flags/th.svg create mode 100644 frontend/public/flags/tr.svg create mode 100644 frontend/public/flags/tw.svg create mode 100644 frontend/public/flags/ua.svg create mode 100644 frontend/public/flags/us.svg create mode 100644 frontend/public/flags/uz.svg create mode 100644 frontend/public/flags/vn.svg create mode 100644 frontend/public/flags/za.svg create mode 100644 frontend/src/translator/runner.tsx create mode 100644 frontend/src/ui/LanguagePicker.tsx create mode 100644 frontend/src/ui/Pager.tsx create mode 100644 frontend/src/ui/language-catalog.ts create mode 100644 frontend/src/ui/paging.ts create mode 100644 frontend/tests/language-catalog.test.mjs create mode 100644 frontend/tests/paging.test.mjs diff --git a/backend/app/routers/i18n.py b/backend/app/routers/i18n.py index 01d1fbc..ed410af 100644 --- a/backend/app/routers/i18n.py +++ b/backend/app/routers/i18n.py @@ -18,7 +18,6 @@ router = APIRouter(prefix="/api/versions/{version_id}/i18n", tags=["i18n"]) Player = Annotated[User, Depends(require("games.play"))] Translator = Annotated[User, Depends(require("games.play", "translations.edit"))] -TODO_STATES = {"untranslated", "outdated", "fuzzy", "machine"} LANG_RE = r"^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$" FONT_EXTS = (".ttf", ".otf", ".woff", ".woff2") MAX_FONT_BYTES = 30 * 1024 * 1024 @@ -84,56 +83,12 @@ async def overview(version_id: int, user: Player, db: DB) -> dict[str, Any]: @router.get("/{lang}/entries") async def entries(version_id: int, lang: str, user: Player, db: DB, kind: str = "lines", - state: str = "all", module: str = "", q: str = "", offset: int = 0, - limit: int = Query(50, ge=1, le=200)) -> dict[str, Any]: + state: str = "all", module: str = "", q: str = "", offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), locate: str = "") -> dict[str, Any]: v = await ready_version(db, user, version_id) - needle = q.strip().lower() - - def matches(st: str, *texts: str) -> bool: - if state == "todo" and st not in TODO_STATES: - return False - if state not in ("all", "todo") and st != state: - return False - return not needle or any(needle in t.lower() for t in texts if t) - - def build() -> dict[str, Any]: - idx = tr.text_index(v.manifest) - lines, strings, _ = tr.locale_tables(v.manifest, lang) - files = v.manifest["files"] - items: list[dict[str, Any]] = [] - total = 0 - if kind == "strings": - for src, usage in idx.strings.items(): - e = strings.get(src) or {} - st = e.get("state", "translated") if e.get("t") else "untranslated" - if not matches(st, src, e.get("t", "")): - continue - total += 1 - if offset < total <= offset + limit: - items.append({"key": src, "usage": usage, "source": src, - "translation": e.get("t", ""), "state": st}) - else: - for info in idx.lines: - if module and info.module != module: - continue - e = lines.get(info.tid) - st = tr.line_state(e, info) - if not matches(st, info.source, (e or {}).get("t", ""), info.who): - continue - total += 1 - if offset < total <= offset + limit: - scene = files.get(info.scene) if info.scene else None - items.append({ - "key": info.tid, "nodeId": info.node_id, "module": info.module, - "label": info.label, "who": info.who, "source": info.source, - "resource": info.resource, - "prev": info.prev, "next": info.next, - "scene": f"/blobs/{scene['hash']}" if scene else None, - "translation": (e or {}).get("t", ""), "state": st, - }) - return {"total": total, "items": items} - - return await run_in_threadpool(build) + return await run_in_threadpool( + lambda: tr.entries_page(v.manifest, lang, kind=kind, state=state, module=module, q=q, + offset=offset, limit=limit, locate=locate)) @router.get("/{lang}/meta") diff --git a/backend/app/translation.py b/backend/app/translation.py index 9267cce..45efb44 100644 --- a/backend/app/translation.py +++ b/backend/app/translation.py @@ -470,3 +470,65 @@ def parse_csv(text: str) -> tuple[dict[str, Any], dict[str, Any]]: elif row.get("kind") == "line": lines[row["key"]] = value return lines, strings + + +TODO_STATES = {"untranslated", "outdated", "fuzzy", "machine"} + + +def entries_page(manifest: dict[str, Any], lang: str, *, kind: str = "lines", state: str = "all", + module: str = "", q: str = "", offset: int = 0, limit: int = 50, + locate: str = "") -> dict[str, Any]: + """One page of the list the editor shows, in the order the editor shows it. + + ``locate`` is the key of a line: ``located`` is its position in this same filtered list + (``None`` when the line is not in it), so the editor can open the page that holds it. + """ + needle = q.strip().lower() + + def matches(st: str, *texts: str) -> bool: + if state == "todo" and st not in TODO_STATES: + return False + if state not in ("all", "todo") and st != state: + return False + return not needle or any(needle in t.lower() for t in texts if t) + + idx = text_index(manifest) + lines, strings, _ = locale_tables(manifest, lang) + files = manifest["files"] + items: list[dict[str, Any]] = [] + total = 0 + located: int | None = None + if kind == "strings": + for src, usage in idx.strings.items(): + e = strings.get(src) or {} + st = e.get("state", "translated") if e.get("t") else "untranslated" + if not matches(st, src, e.get("t", "")): + continue + total += 1 + if locate and src == locate: + located = total - 1 + if offset < total <= offset + limit: + items.append({"key": src, "usage": usage, "source": src, + "translation": e.get("t", ""), "state": st}) + else: + for info in idx.lines: + if module and info.module != module: + continue + e = lines.get(info.tid) + st = line_state(e, info) + if not matches(st, info.source, (e or {}).get("t", ""), info.who): + continue + total += 1 + if locate and info.tid == locate: + located = total - 1 + if offset < total <= offset + limit: + scene = files.get(info.scene) if info.scene else None + items.append({ + "key": info.tid, "nodeId": info.node_id, "module": info.module, + "label": info.label, "who": info.who, "source": info.source, + "resource": info.resource, + "prev": info.prev, "next": info.next, + "scene": f"/blobs/{scene['hash']}" if scene else None, + "translation": (e or {}).get("t", ""), "state": st, + }) + return {"total": total, "items": items, "located": located} diff --git a/backend/tests/test_translation.py b/backend/tests/test_translation.py index 59e34a0..51b7f97 100644 --- a/backend/tests/test_translation.py +++ b/backend/tests/test_translation.py @@ -80,3 +80,19 @@ def test_po_and_csv_roundtrip(manifest): st = tr.stats(m, "de") assert st["translated"] == 1 and st["stringsTranslated"] == 1 assert json.dumps(st) + + +def test_entries_page_locates_a_line_in_the_filtered_list(manifest): + idx = tr.text_index(manifest) + first, second = idx.lines[0], idx.lines[1] + page = tr.entries_page(manifest, "ru", state="untranslated", limit=1, locate=second.tid) + assert page["located"] == 1 and page["total"] == len(idx.lines) and len(page["items"]) == 1 + + m = tr.apply_changes(manifest, "ru", lines={first.tid: {"t": "Привет", "state": "machine"}}) + # a translated line leaves the "untranslated" list, so the next one moves up + assert tr.entries_page(m, "ru", state="untranslated", locate=second.tid)["located"] == 0 + assert tr.entries_page(m, "ru", state="untranslated", locate=first.tid)["located"] is None + # ...but stays in "todo" because machine translations still await a review + assert tr.entries_page(m, "ru", state="todo", locate=first.tid)["located"] == 0 + assert tr.entries_page(m, "ru", locate="")["located"] is None + assert tr.entries_page(m, "ru", kind="strings", locate="Эйлин")["located"] is not None diff --git a/docs/LOCALIZATION.md b/docs/LOCALIZATION.md index f17ed2a..9ba873a 100644 --- a/docs/LOCALIZATION.md +++ b/docs/LOCALIZATION.md @@ -213,11 +213,22 @@ translate russian strings: `PGames: <игра> (<исходный> → <язык>)`: контекст из описания игры, глоссарий из вкладки «Глоссарий», пожелания пользователя. Проект находится по имени и обновляется перед каждым запуском; +* запуск живёт в провайдере выше страниц (`translator/runner.tsx`): перевод идёт, + пока вкладка открыта, даже если уйти из редактора; его карточка с прогрессом и + кнопкой «Остановить» стоит в том же плавающем окне, что и загрузки. Одновременно + работает только один перевод (на все игры и языки), пока он идёт, кнопки + отдельных строк недоступны; закрытие вкладки браузер подтверждает; * автоперевод идёт по списку так, как его видит пользователь: текущая вкладка («Реплики» или «Имена и меню»), фильтр по файлу и поиск, сверху вниз. Берутся строки «не переведено» (и «устарело» по желанию) страницами по 40; страница уходит одним заданием, готовое сразу сохраняется, появляется в открытых строках и обновляет прогресс в шапке; +* список разбит на страницы (20/40/100/200 строк, номера страниц, переход по + номеру); сервер в `GET …/entries` по параметру `locate=<ключ>` возвращает + `located`, позицию строки в том же отфильтрованном списке. По нему режим + «Следить за переводом» (включён по умолчанию, выключается кнопкой над списком, + флажком в панели или ручным листанием) открывает страницу, на которой модель + работает сейчас, и подсвечивает строки текущей пачки; * у каждой строки есть кнопка «Перевести» (пока переводчик подключён): она переводит одну строку с тем же контекстом и глоссарием и помечает её «Машинный». Редактор ищет переводчик сам, только если он уже работал в этом diff --git a/frontend/public/flags/LICENSE.txt b/frontend/public/flags/LICENSE.txt new file mode 100644 index 0000000..84e028a --- /dev/null +++ b/frontend/public/flags/LICENSE.txt @@ -0,0 +1,22 @@ +(The MIT License) + +Copyright (c) 2020 @catamphetamine + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/frontend/public/flags/al.svg b/frontend/public/flags/al.svg new file mode 100644 index 0000000..d7989d9 --- /dev/null +++ b/frontend/public/flags/al.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/am.svg b/frontend/public/flags/am.svg new file mode 100644 index 0000000..d49087d --- /dev/null +++ b/frontend/public/flags/am.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/az.svg b/frontend/public/flags/az.svg new file mode 100644 index 0000000..486d3b8 --- /dev/null +++ b/frontend/public/flags/az.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/ba.svg b/frontend/public/flags/ba.svg new file mode 100644 index 0000000..7ad7d94 --- /dev/null +++ b/frontend/public/flags/ba.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/bd.svg b/frontend/public/flags/bd.svg new file mode 100644 index 0000000..e3c7267 --- /dev/null +++ b/frontend/public/flags/bd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/bg.svg b/frontend/public/flags/bg.svg new file mode 100644 index 0000000..7ebee38 --- /dev/null +++ b/frontend/public/flags/bg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/br.svg b/frontend/public/flags/br.svg new file mode 100644 index 0000000..f4dbb02 --- /dev/null +++ b/frontend/public/flags/br.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/by.svg b/frontend/public/flags/by.svg new file mode 100644 index 0000000..86a423a --- /dev/null +++ b/frontend/public/flags/by.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/cn.svg b/frontend/public/flags/cn.svg new file mode 100644 index 0000000..64629b2 --- /dev/null +++ b/frontend/public/flags/cn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/cz.svg b/frontend/public/flags/cz.svg new file mode 100644 index 0000000..c6ae614 --- /dev/null +++ b/frontend/public/flags/cz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/de.svg b/frontend/public/flags/de.svg new file mode 100644 index 0000000..295d13e --- /dev/null +++ b/frontend/public/flags/de.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/dk.svg b/frontend/public/flags/dk.svg new file mode 100644 index 0000000..1fecf56 --- /dev/null +++ b/frontend/public/flags/dk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/ee.svg b/frontend/public/flags/ee.svg new file mode 100644 index 0000000..28a9e16 --- /dev/null +++ b/frontend/public/flags/ee.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/es.svg b/frontend/public/flags/es.svg new file mode 100644 index 0000000..e143324 --- /dev/null +++ b/frontend/public/flags/es.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/fi.svg b/frontend/public/flags/fi.svg new file mode 100644 index 0000000..285363e --- /dev/null +++ b/frontend/public/flags/fi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/fr.svg b/frontend/public/flags/fr.svg new file mode 100644 index 0000000..ec311d7 --- /dev/null +++ b/frontend/public/flags/fr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/gb.svg b/frontend/public/flags/gb.svg new file mode 100644 index 0000000..1f18f18 --- /dev/null +++ b/frontend/public/flags/gb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/ge.svg b/frontend/public/flags/ge.svg new file mode 100644 index 0000000..d4afde9 --- /dev/null +++ b/frontend/public/flags/ge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/gr.svg b/frontend/public/flags/gr.svg new file mode 100644 index 0000000..0067170 --- /dev/null +++ b/frontend/public/flags/gr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/hr.svg b/frontend/public/flags/hr.svg new file mode 100644 index 0000000..c61e8c8 --- /dev/null +++ b/frontend/public/flags/hr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/hu.svg b/frontend/public/flags/hu.svg new file mode 100644 index 0000000..8d92a84 --- /dev/null +++ b/frontend/public/flags/hu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/id.svg b/frontend/public/flags/id.svg new file mode 100644 index 0000000..2684a3d --- /dev/null +++ b/frontend/public/flags/id.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/ie.svg b/frontend/public/flags/ie.svg new file mode 100644 index 0000000..f3f99d9 --- /dev/null +++ b/frontend/public/flags/ie.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/il.svg b/frontend/public/flags/il.svg new file mode 100644 index 0000000..da41218 --- /dev/null +++ b/frontend/public/flags/il.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/in.svg b/frontend/public/flags/in.svg new file mode 100644 index 0000000..9f6e566 --- /dev/null +++ b/frontend/public/flags/in.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/ir.svg b/frontend/public/flags/ir.svg new file mode 100644 index 0000000..5f4ba03 --- /dev/null +++ b/frontend/public/flags/ir.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/is.svg b/frontend/public/flags/is.svg new file mode 100644 index 0000000..43cf1d4 --- /dev/null +++ b/frontend/public/flags/is.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/it.svg b/frontend/public/flags/it.svg new file mode 100644 index 0000000..2eb1289 --- /dev/null +++ b/frontend/public/flags/it.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/jp.svg b/frontend/public/flags/jp.svg new file mode 100644 index 0000000..8714514 --- /dev/null +++ b/frontend/public/flags/jp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/ke.svg b/frontend/public/flags/ke.svg new file mode 100644 index 0000000..ff76773 --- /dev/null +++ b/frontend/public/flags/ke.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/kr.svg b/frontend/public/flags/kr.svg new file mode 100644 index 0000000..e86b888 --- /dev/null +++ b/frontend/public/flags/kr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/kz.svg b/frontend/public/flags/kz.svg new file mode 100644 index 0000000..bb020c9 --- /dev/null +++ b/frontend/public/flags/kz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/lt.svg b/frontend/public/flags/lt.svg new file mode 100644 index 0000000..47343ca --- /dev/null +++ b/frontend/public/flags/lt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/lu.svg b/frontend/public/flags/lu.svg new file mode 100644 index 0000000..8aa2421 --- /dev/null +++ b/frontend/public/flags/lu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/lv.svg b/frontend/public/flags/lv.svg new file mode 100644 index 0000000..a420dbf --- /dev/null +++ b/frontend/public/flags/lv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/mk.svg b/frontend/public/flags/mk.svg new file mode 100644 index 0000000..d44b445 --- /dev/null +++ b/frontend/public/flags/mk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/mn.svg b/frontend/public/flags/mn.svg new file mode 100644 index 0000000..7cf9781 --- /dev/null +++ b/frontend/public/flags/mn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/mt.svg b/frontend/public/flags/mt.svg new file mode 100644 index 0000000..e144b0c --- /dev/null +++ b/frontend/public/flags/mt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/mx.svg b/frontend/public/flags/mx.svg new file mode 100644 index 0000000..bbdf84d --- /dev/null +++ b/frontend/public/flags/mx.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/my.svg b/frontend/public/flags/my.svg new file mode 100644 index 0000000..38cc877 --- /dev/null +++ b/frontend/public/flags/my.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/nl.svg b/frontend/public/flags/nl.svg new file mode 100644 index 0000000..3c762c3 --- /dev/null +++ b/frontend/public/flags/nl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/no.svg b/frontend/public/flags/no.svg new file mode 100644 index 0000000..e12299e --- /dev/null +++ b/frontend/public/flags/no.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/ph.svg b/frontend/public/flags/ph.svg new file mode 100644 index 0000000..0ca2e3f --- /dev/null +++ b/frontend/public/flags/ph.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/pk.svg b/frontend/public/flags/pk.svg new file mode 100644 index 0000000..96d75be --- /dev/null +++ b/frontend/public/flags/pk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/pl.svg b/frontend/public/flags/pl.svg new file mode 100644 index 0000000..e98271f --- /dev/null +++ b/frontend/public/flags/pl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/pt.svg b/frontend/public/flags/pt.svg new file mode 100644 index 0000000..51d7c80 --- /dev/null +++ b/frontend/public/flags/pt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/ro.svg b/frontend/public/flags/ro.svg new file mode 100644 index 0000000..2be793b --- /dev/null +++ b/frontend/public/flags/ro.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/rs.svg b/frontend/public/flags/rs.svg new file mode 100644 index 0000000..7410acd --- /dev/null +++ b/frontend/public/flags/rs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/ru.svg b/frontend/public/flags/ru.svg new file mode 100644 index 0000000..5e78ce8 --- /dev/null +++ b/frontend/public/flags/ru.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/sa.svg b/frontend/public/flags/sa.svg new file mode 100644 index 0000000..9632754 --- /dev/null +++ b/frontend/public/flags/sa.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/se.svg b/frontend/public/flags/se.svg new file mode 100644 index 0000000..c4efc4b --- /dev/null +++ b/frontend/public/flags/se.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/si.svg b/frontend/public/flags/si.svg new file mode 100644 index 0000000..67d17df --- /dev/null +++ b/frontend/public/flags/si.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/sk.svg b/frontend/public/flags/sk.svg new file mode 100644 index 0000000..300f814 --- /dev/null +++ b/frontend/public/flags/sk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/th.svg b/frontend/public/flags/th.svg new file mode 100644 index 0000000..01b837c --- /dev/null +++ b/frontend/public/flags/th.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/tr.svg b/frontend/public/flags/tr.svg new file mode 100644 index 0000000..1e08e23 --- /dev/null +++ b/frontend/public/flags/tr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/tw.svg b/frontend/public/flags/tw.svg new file mode 100644 index 0000000..c3660f1 --- /dev/null +++ b/frontend/public/flags/tw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/ua.svg b/frontend/public/flags/ua.svg new file mode 100644 index 0000000..8eaa507 --- /dev/null +++ b/frontend/public/flags/ua.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/us.svg b/frontend/public/flags/us.svg new file mode 100644 index 0000000..dc427e7 --- /dev/null +++ b/frontend/public/flags/us.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/uz.svg b/frontend/public/flags/uz.svg new file mode 100644 index 0000000..b83eacb --- /dev/null +++ b/frontend/public/flags/uz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/vn.svg b/frontend/public/flags/vn.svg new file mode 100644 index 0000000..d52233d --- /dev/null +++ b/frontend/public/flags/vn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/za.svg b/frontend/public/flags/za.svg new file mode 100644 index 0000000..1b294c9 --- /dev/null +++ b/frontend/public/flags/za.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6f73c67..93d732b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ; return ( + } /> @@ -140,5 +142,6 @@ export function App() { /> + ); } diff --git a/frontend/src/pages/TranslationEditor.tsx b/frontend/src/pages/TranslationEditor.tsx index ed16a97..879c872 100644 --- a/frontend/src/pages/TranslationEditor.tsx +++ b/frontend/src/pages/TranslationEditor.tsx @@ -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(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(() => { + 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 = {}; + 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 (
+ {picking && setPicking(false)} />} К игре ))} {ov.editable && ( - + )}
{stats && ( @@ -214,14 +258,14 @@ export function TranslationEditor() { {machine && ov.editable && stats && ( - setMachine(false)} /> + setMachine(false)} /> )} {!ov.languages.length ? ( void addLanguage()}>Добавить язык : undefined} /> + action={ov.editable ? : undefined} /> ) : stats ? ( <>
@@ -231,16 +275,18 @@ export function TranslationEditor() { ))}
+ ov={ov} onStats={onStats} session={session} fills={fills.current} fillTick={fillTick} onView={setView} + working={working} runBusy={run.busy} follow={follow} onFollow={setFollow} /> ) : null} ); } -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) => void; session: Session; fills: Record>; 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(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(null); const [glossary, setGlossary] = useState([]); const [saving, setSaving] = useState<"idle" | "saving" | "saved" | "error">("idle"); const pending = useRef>({}); const timer = useRef(); 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="Поиск" /> {tab === "lines" && ov.modules.length > 1 && ( - filtered(() => setModule(e.target.value))} aria-label="Файл скрипта"> {ov.modules.map((m) => )} @@ -359,10 +458,16 @@ function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTi {saving === "saving" ? "Сохранение…" : saving === "saved" ? "Сохранено" : saving === "error" ? "Ошибка сохранения" : ""} + {working.active && working.kind === tab && ( + + )}
{FILTERS.map(([v, label]) => ( - + ))}
@@ -370,11 +475,28 @@ function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTi ) : ( <> -
Найдено: {total}
-
+
+ + Найдено: {total} · показаны {page * size + 1}–{Math.min(total, page * size + items.length)} + + + +
+
{items.map((it, i) => ( 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 }} /> ))}
- {items.length < total && ( -
- -
- )} +
)}
); } -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 ( -
+
{item.who ? {item.who} : item.usage ? {USAGE[item.usage] ?? item.usage} : рассказчик} @@ -469,8 +591,8 @@ function Row({ item, versionId, lang, editable, canApprove, terms, machine, onCh {editable && (
{machine && ( - )} diff --git a/frontend/src/pages/translate.css b/frontend/src/pages/translate.css index 55504e4..9017572 100644 --- a/frontend/src/pages/translate.css +++ b/frontend/src/pages/translate.css @@ -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; } } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 96ddb21..53eb3ae 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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); } } diff --git a/frontend/src/translator/MachineTranslate.tsx b/frontend/src/translator/MachineTranslate.tsx index f47739b..a875591 100644 --- a/frontend/src/translator/MachineTranslate.tsx +++ b/frontend/src/translator/MachineTranslate.tsx @@ -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) => 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(null); - const [running, setRunning] = useState(false); - const [progress, setProgress] = useState(null); + const [preparing, setPreparing] = useState(false); const [error, setError] = useState(""); - const [summary, setSummary] = useState(""); - const abort = useRef(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(() => (prefs.outdated ? ["untranslated", "outdated"] : ["untranslated"]), [prefs.outdated]); + const states = useMemo(() => (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>(`/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 (

Машинный перевод

- +

Перевод выполняет программа на вашем компьютере, сайт только получает готовые строки с пометкой «Машинный». Как установить и настроить. Отдельную строку можно перевести - кнопкой «Перевести» рядом с ней. + кнопкой «Перевести» рядом с ней. Перевод идёт в фоне: ход виден в окне справа внизу, можно переходить по сайту.

{(link === "idle" || link === "checking") &&

Ищу переводчик на {client.base}…

} @@ -139,18 +113,27 @@ export function MachineTranslate({ session, versionId, lang, view, onFill, onSta )} {health?.startup_errors[prefs.provider] && {health.startup_errors[prefs.provider]}} + {elsewhere && task && ( +
+ + Сейчас идёт другой перевод: «{task.title}» ({task.lang}). Одновременно работает только один. + Дождитесь его окончания или остановите.{" "} + +
+ )} + {ready && ( <>
@@ -169,16 +152,18 @@ export function MachineTranslate({ session, versionId, lang, view, onFill, onSta