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

+5 -50
View File
@@ -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")
+62
View File
@@ -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}
+16
View File
@@ -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
+11
View File
@@ -213,11 +213,22 @@ translate russian strings:
`PGames: <игра> (<исходный> → <язык>)`: контекст из описания игры, глоссарий
из вкладки «Глоссарий», пожелания пользователя. Проект находится по имени и
обновляется перед каждым запуском;
* запуск живёт в провайдере выше страниц (`translator/runner.tsx`): перевод идёт,
пока вкладка открыта, даже если уйти из редактора; его карточка с прогрессом и
кнопкой «Остановить» стоит в том же плавающем окне, что и загрузки. Одновременно
работает только один перевод (на все игры и языки), пока он идёт, кнопки
отдельных строк недоступны; закрытие вкладки браузер подтверждает;
* автоперевод идёт по списку так, как его видит пользователь: текущая
вкладка («Реплики» или «Имена и меню»), фильтр по файлу и поиск, сверху вниз.
Берутся строки «не переведено» (и «устарело» по желанию) страницами по 40;
страница уходит одним заданием, готовое сразу сохраняется, появляется в
открытых строках и обновляет прогресс в шапке;
* список разбит на страницы (20/40/100/200 строк, номера страниц, переход по
номеру); сервер в `GET …/entries` по параметру `locate=<ключ>` возвращает
`located`, позицию строки в том же отфильтрованном списке. По нему режим
«Следить за переводом» (включён по умолчанию, выключается кнопкой над списком,
флажком в панели или ручным листанием) открывает страницу, на которой модель
работает сейчас, и подсвечивает строки текущей пачки;
* у каждой строки есть кнопка «Перевести» (пока переводчик подключён): она
переводит одну строку с тем же контекстом и глоссарием и помечает её
«Машинный». Редактор ищет переводчик сам, только если он уже работал в этом
+22
View File
@@ -0,0 +1,22 @@
(The MIT License)
Copyright (c) 2020 @catamphetamine <purecatamphetamine@gmail.com>
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.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 90 60"><path fill="#ED2024" d="M0 0h90v60H0z"/><path fill="#212121" d="M32.5 12.2v16l7.1 2.5-5 4.9 2.4 3 5.3-5.5 1.2 2.3-3 4.9 3.6 5.6-1.8 2.6 2.8 3.6 2.7-3.7-1.5-2.6 3-5.8-2.7-4.6 1.2-2.3 5.2 5.5 2.5-2.9-5.2-5.1 7.2-2.6V12.2l-5.2 1.9-.1 3.8-3.3.3v-2.6l1.7-2.3 5.6-2.2-2.3-.4 1.4-1.2.9.4-.8-1.4L54 9l-1-1.1L47.5 9l1.4 1.1-3.8 4.9-3.8-4.8 1.3-1.1-5-1.2L36.2 9l-1.5-.4-.8 1.4.9-.6 1.6 1.1-2.3.5 5.5 2 1.6 2.3v2.9l-3.3-.3v-3.6z"/><g fill="#212121"><path d="M26.2 25h6.4v2.5h-6.4zM26.2 20.9h6.4v2.7h-6.4zM26.2 16.7h6.4v2.6h-6.4zM26.2 12.7h6.4v2.5h-6.4z"/></g><g fill="#212121"><path d="M57.4 25h6.4v2.5h-6.4zM57.4 20.9h6.4v2.7h-6.4zM57.4 16.7h6.4v2.6h-6.4zM57.4 12.7h6.4v2.5h-6.4z"/></g><path fill="#212121" d="M53.4 36.1h6.4V38h-6.4zM30.2 36.1h6.4V38h-6.4z"/></svg>

After

Width:  |  Height:  |  Size: 815 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#0052B4" d="M0 0h513v342H0z"/><path fill="#D80027" d="M0 0h513v114H0z"/><path fill="#FF9811" d="M0 228h513v114H0z"/></svg>

After

Width:  |  Height:  |  Size: 196 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#ef3340" d="M0 0h513v342H0z"/><path fill="#00b5e2" d="M0 0h513v114H0z"/><path fill="#509e2f" d="M0 228h513v114H0z"/><g fill="#FFF"><path d="M265.6 212.6c-23 0-41.6-18.6-41.6-41.6s18.6-41.6 41.6-41.6c7.2 0 13.9 1.8 19.8 5-9.2-9-21.9-14.6-35.8-14.6-28.3 0-51.2 22.9-51.2 51.2s22.9 51.2 51.2 51.2c13.9 0 26.6-5.6 35.8-14.6-5.9 3.2-12.6 5-19.8 5z"/><path d="m297.6 142.2 5.5 15.5 14.9-7.1-7.1 14.9 15.5 5.5-15.5 5.5 7.1 14.9-14.9-7.1-5.5 15.5-5.5-15.5-14.9 7.1 7.1-14.9-15.5-5.5 15.5-5.5-7.1-14.9 14.9 7.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 589 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#10338C" d="M0 0h513v342H0z"/><path fill="#F6C500" d="M99.9 0h342v342"/><g fill="#FFF"><path d="M19.9 21.1 40.3 6.2 60.9 21 54 0H26.7z"/><path d="M92.3 25.5 84.4 1.3l-7.8 24.2H51.3l20.5 14.8-7.8 24 20.4-14.8L105 64.3l-7.9-24 20.5-14.9z"/><path d="m136.3 69.2-7.8-24-7.8 24H95.5l20.4 14.9-7.8 24 20.4-14.8 20.6 14.8-7.8-24 20.4-14.9z"/><path d="m179.4 112.3-7.8-24.1-7.8 24.1h-25.3l20.5 14.9-7.8 24 20.4-14.8 20.6 14.8-7.9-24 20.5-15z"/><path d="m222.5 155.3-7.8-24-7.8 24h-25.4l20.6 15-7.8 24 20.4-14.9 20.4 14.9-7.8-24 20.6-15z"/><path d="m265.6 198.4-7.8-24-7.8 24h-25.4l20.6 14.9-7.8 24.1 20.4-14.9 20.4 14.9-7.8-24.2 20.5-14.8z"/><path d="m308.7 241.5-7.9-24-7.8 24h-25.3l20.5 14.8-7.9 24.2 20.6-14.9 20.4 14.8-7.8-24.1 20.5-14.8z"/><path d="m351.7 284.6-7.8-24.2-7.8 24.2h-25.3l20.4 14.8-7.8 24.1 20.5-14.9 20.5 14.9-7.8-24.1 20.4-14.8z"/><path d="m387 303.5-7.9 24.1-25.3.1 19.8 14.3h26.6l19.9-14.4h-25.3z"/></g></svg>

After

Width:  |  Height:  |  Size: 998 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 85.333 513 342"><path fill="#2d6e41" d="M0 85.331h513v342H0z"/><circle fill="#F40B32" cx="218.902" cy="256.5" r="115"/></svg>

After

Width:  |  Height:  |  Size: 176 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#00966e" d="M0 0h513v342H0z"/><path fill="#FFF" d="M0 0h513v114H0z"/><path fill="#d62612" d="M0 228h513v114H0z"/></svg>

After

Width:  |  Height:  |  Size: 193 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#009b3a" d="M0 0h513v342H0z"/><path fill="#fedf00" d="m256.5 19.3 204.9 151.4L256.5 322 50.6 170.7z"/><circle fill="#FFF" cx="256.5" cy="171" r="80.4"/><path fill="#002776" d="M215.9 165.7c-13.9 0-27.4 2.1-40.1 6 .6 43.9 36.3 79.3 80.3 79.3 27.2 0 51.3-13.6 65.8-34.3-24.9-31-63.2-51-106-51zM334.9 186c.9-5 1.5-10.1 1.5-15.4 0-44.4-36-80.4-80.4-80.4-33.1 0-61.5 20.1-73.9 48.6 10.9-2.2 22.1-3.4 33.6-3.4 46.8.1 89 19.5 119.2 50.6z"/></svg>

After

Width:  |  Height:  |  Size: 513 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#007C30" d="M0 0h513v342H0z"/><path fill="#CE1720" d="M0 230h513V0H0"/><path fill="#FFF" d="M100 230V0H0v342h513-413z"/><g fill="#CE1720"><path d="M28 159.6 8.6 128.2 28 97.4l19.3 30.8zM72.6 159.6l-19.3-31.4 19.3-30.8L92 128.2zM28 241.2 8.6 209.8 28 179l19.3 30.8zM72.6 241.2l-19.3-31.4L72.6 179 92 209.8z"/></g><g fill="none" stroke="#CE1720" stroke-width="7"><path d="m28 73.8-16.1-26L28 22.2l16 25.6zM72.6 73.8l-16-26 16-25.6 16.1 25.6zM28 318l-16.1-26.1L28 266.3l16 25.6zM72.6 318l-16-26.1 16-25.6 16.1 25.6z"/></g></svg>

After

Width:  |  Height:  |  Size: 599 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#D80027" d="M0 0h513v342H0z"/><g fill="#FFDA44"><path d="m226.8 239.2-9.7-15.6-17.9 4.4 11.9-14.1-9.7-15.6 17.1 6.9 11.8-14.1-1.3 18.4 17.1 6.9-17.9 4.4zM290.6 82l-10.1 15.4 11.6 14.3-17.7-4.8-10.1 15.5-1-18.4-17.7-4.8 17.2-6.6-1-18.4 11.6 14.3zM236.2 25.4l-2 18.3 16.8 7.6-18 3.8-2 18.3-9.2-16-17.9 3.8 12.3-13.7-9.2-15.9 16.8 7.5zM292.8 161.8l-14.9 10.9 5.8 17.5-14.9-10.8-14.9 11 5.6-17.6-14.9-10.7 18.4-.1 5.6-17.6 5.8 17.5zM115 46.3l17.3 53.5h56.2l-45.4 32.9 17.3 53.5-45.4-33-45.5 33 17.4-53.5-45.5-32.9h56.3z"/></g></svg>

After

Width:  |  Height:  |  Size: 602 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#11457e" d="M0 0h513v342H0z"/><path fill="#d7141a" d="M513 171v171H0l256.5-171z"/><path fill="#FFF" d="M513 0v171H256.5L0 0z"/></svg>

After

Width:  |  Height:  |  Size: 207 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#D80027" d="M0 0h513v342H0z"/><path d="M0 0h513v114H0z"/><path fill="#FFDA44" d="M0 228h513v114H0z"/></svg>

After

Width:  |  Height:  |  Size: 181 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#c60c30" d="M0 0h513v342H0z"/><path fill="#FFF" d="M190 0h-60v140H0v60h130v142h60V200h323v-60H190z"/></svg>

After

Width:  |  Height:  |  Size: 181 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#111" d="M0 0h513v342H0z"/><path fill="#368FD8" d="M0 0h513v114H0z"/><path fill="#FFF" d="M0 228h513v114H0z"/></svg>

After

Width:  |  Height:  |  Size: 190 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22.5 15"><path fill="#FFF" d="M0 0h22.5v15H0V0z"/><path fill="#D03433" d="M0 0h22.5v4H0V0zm0 11h22.5v4H0v-4z"/><path fill="#FBCA46" d="M0 4h22.5v7H0V4z"/><path fill="#FFF" d="M7.8 7h1v.5h-1V7z"/><path fill="#A41517" d="M7.2 8.5c0 .3.3.5.6.5s.6-.2.6-.5L8.5 7H7.1l.1 1.5zM6.6 7c0-.3.2-.5.4-.5h1.5c.3 0 .5.2.5.4V7l-.1 1.5c-.1.6-.5 1-1.1 1-.6 0-1-.4-1.1-1L6.6 7z"/><path fill="#A41517" d="M6.8 7.5h2V8h-.5l-.5 1-.5-1h-.5v-.5zM5.3 6h1v3.5h-1V6zm4 0h1v3.5h-1V6zm-2.5-.5c0-.3.2-.5.5-.5h1c.3 0 .5.2.5.5v.2c0 .2-.1.3-.3.3H7c-.1 0-.2-.1-.2-.2v-.3z"/></svg>

After

Width:  |  Height:  |  Size: 599 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v342H0z"/><path fill="#2E52B2" d="M513 129.3V212H203.7v130H121V212H0v-82.7h121V0h82.7v129.3z"/></svg>

After

Width:  |  Height:  |  Size: 192 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v342H0z"/><path fill="#00318A" d="M0 0h171v342H0z"/><path fill="#D80027" d="M342 0h171v342H342z"/></svg>

After

Width:  |  Height:  |  Size: 195 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><g fill="#FFF"><path d="M0 0h513v341.3H0V0z"/><path d="M311.7 230 513 341.3v-31.5L369.3 230h-57.6zM200.3 111.3 0 0v31.5l143.7 79.8h56.6z"/></g><g fill="#012169"><path d="M393.8 230 513 295.7V230H393.8zm-82.1 0L513 341.3v-31.5L369.3 230h-57.6zm146.9 111.3-147-81.7v81.7h147zM90.3 230 0 280.2V230h90.3zm110 14.2v97.2H25.5l174.8-97.2zM118.2 111.3 0 45.6v65.7h118.2zm82.1 0L0 0v31.5l143.7 79.8h56.6zM53.4 0l147 81.7V0h-147zM421.7 111.3 513 61.1v50.2h-91.3zm-110-14.2V0h174.9L311.7 97.1z"/></g><g fill="#c8102e"><path d="M288 0h-64v138.7H0v64h224v138.7h64V202.7h224v-64H288V0z"/><path d="M311.7 230 513 341.3v-31.5L369.3 230h-57.6zM143.7 230 0 309.9v31.5L200.3 230h-56.6zM200.3 111.3 0 0v31.5l143.7 79.8h56.6zM368.3 111.3 513 31.5V0L311.7 111.3h56.6z"/></g></svg>

After

Width:  |  Height:  |  Size: 820 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22.5 15"><path fill="#fff" d="M0 0h22.5v15H0z"/><path fill="#eb000e" d="M9.8 6H0v3h9.8v6h3V9h9.8V6h-9.8V0h-3v6z"/><path fill="#eb000e" d="m17.3 2.7-.2-1.2h1l-.2 1.2 1.2-.2v1l-1.2-.2.2 1.2h-1l.2-1.2-1.2.2v-1l1.2.2zm-12.7 0-.2-1.2h1l-.2 1.2 1.2-.2v1l-1.2-.2.2 1.2h-1l.2-1.2-1.2.2v-1l1.2.2zm0 9-.2-1.2h1l-.2 1.2 1.2-.2v1l-1.2-.2.2 1.2h-1l.2-1.2-1.2.2v-1l1.2.2zm12.7 0-.2-1.2h1l-.2 1.2 1.2-.2v1l-1.2-.2.2 1.2h-1l.2-1.2-1.2.2v-1l1.2.2z"/></svg>

After

Width:  |  Height:  |  Size: 492 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v342H0z"/><g fill="#0d5eaf"><path d="M0 0h513v38H0zM0 76h513v38H0zM0 152h513v38H0zM0 228h513v38H0zM0 304h513v38H0z"/><path d="M0 0h190v190H0z"/></g><g fill="#FFF"><path d="M0 76h190v38H0z"/><path d="M76 0h38v190H76z"/></g></svg>

After

Width:  |  Height:  |  Size: 319 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v342H0z"/><path fill="#D80027" d="M0 0h513v114H0z"/><path fill="#0052B4" d="M0 228h513v114H0z"/><g fill="#338AF3"><path d="M309.3 113.2v-44l17.5-14.4 17.7 14.4v44zM203.7 113.2v-44l-17.3-14.4-17.9 14.4v44zM238.9 113.2v-44l17.6-14.4 17.6 14.4v44z"/></g><g fill="#0052B4"><path d="M309.3 113.2h-35.2v-44l17.5-14.4 17.7 14.4zM238.9 113.2h-35.2v-44l17.5-14.4 17.7 14.4z"/></g><path stroke="#D80027" fill="#FFF" d="M168.5 113.2v101.9c0 24.3 14.4 46.2 35.4 59.4 21.3 13.4 42.1 14.7 52.6 14.7s31.4-1.7 52.6-14.8c21-13 35.4-35.1 35.4-59.3V113.2h-176z"/><g fill="#D80027"><path d="M168.5 113.2h35.2v35.2h-35.2zM238.9 113.2h35.2v35.2h-35.2zM309.3 113.2h35.2v35.2h-35.2zM203.7 148.4h35.2v35.2h-35.2zM274.1 148.4h35.2v35.2h-35.2zM168.5 183.6h35.2v35.2h-35.2zM203.7 218.8h35.2V254h-35.2zM238.9 183.6h35.2v35.2h-35.2zM309.3 183.6h35.2v35.2h-35.2zM274.1 218.8h35.2V254h-35.2zM309.3 274.3c8.6-5.4 16.2-12.3 22-20.3h-22v20.3zM181.7 254c5.8 8 13.3 14.9 22 20.4V254h-22zM238.9 254v33.7c7.2 1.2 13.3 1.5 17.6 1.5 4.3 0 10.4-.3 17.6-1.6V254h-35.2z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v342H0z"/><path fill="#D80027" d="M0 0h513v114H0z"/><path fill="#6DA544" d="M0 228h513v114H0z"/></svg>

After

Width:  |  Height:  |  Size: 193 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v342H0z"/><path fill="#E00" d="M0 0h513v171H0z"/></svg>

After

Width:  |  Height:  |  Size: 146 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v342H0z"/><path fill="#6DA544" d="M0 0h171v342H0z"/><path fill="#FF9811" d="M342 0h171v342H342z"/></svg>

After

Width:  |  Height:  |  Size: 195 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v342H0z"/><g fill="#2E52B2"><path d="M340.6 122.4h-56.1l-28-48.6-28 48.6h-56.1l28 48.6-28 48.6h56.1l28 48.6 28-48.6h56.1l-28-48.6 28-48.6zM293.2 171 276 204.2h-38.9L219.8 171l17.2-33.2h38.9l17.3 33.2zm-36.7-71.8 11.9 23.3h-23.9l12-23.3zm-58.3 38.6h23.9l-10.8 21-13.1-21zm0 66.4 13-22.1 11.9 22.1h-24.9zm58.3 37.5-11.9-22.1h23.9l-12 22.1zm59.4-37.5h-25l11.9-22.1 13.1 22.1zm-26.1-66.4h26.1l-13 22.1-13.1-22.1zM0 21.3h512V64H0zM0 277.3h512V320H0z"/></g></svg>

After

Width:  |  Height:  |  Size: 548 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#ff6820" d="M0 0h513v114H0V0z"/><path fill="#FFF" d="M0 114h513v114H0V114z"/><path fill="#046a38" d="M0 228h513v114H0V228z"/><path fill="none" stroke="#07038d" stroke-width="4" d="M256.5 136.8c18.9 0 34.2 15.3 34.2 34.2s-15.3 34.2-34.2 34.2-34.2-15.3-34.2-34.2 15.3-34.2 34.2-34.2z"/><g stroke="#07038d" stroke-width="2"><path d="m265.3 138.2-17.6 65.7m17.6-65.7-17.6 65.7M273.5 141.6l-34 58.9M280.5 147l-48 48M285.9 154 227 188M289.3 162.2l-65.7 17.6M290.5 171h-68M289.3 179.8l-65.7-17.6M285.9 188 227 154M280.5 195l-48-48M273.5 200.4l-34-58.9M265.3 203.8l-17.6-65.7M256.5 205v-68M247.7 203.8l17.6-65.7M239.5 200.4l34-58.9M232.5 195l48.1-48.1M227.1 188l58.9-34M223.7 179.8l65.7-17.6M222.5 171h68M223.7 162.2l65.7 17.6M227.1 154l58.9 34M232.5 147l48.1 48.1M239.5 141.6l34 58.9M247.7 138.2l17.6 65.7"/></g></svg>

After

Width:  |  Height:  |  Size: 885 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h512v342H0z"/><path fill="#6DA544" d="M0 0h513v114H0z"/><g fill="#D80027"><path d="M0 227.9h513v114H0zM278.8 134.8c.1 2 8.7 26.2 4.4 39.4-6.6 20.3-15.8 21.8-19.8 24.5V134l-6.9-4.2-6.9 4.2v64.7c-4-2.7-12.4-2.4-19.8-24.5-4.3-12.7 5.7-37.3 5.8-39.2 0 0-9.5 8.1-15.8 24-5.9 14.8 1.9 49.6 29.5 54.8 2.3.4 4.7 5.6 7.2 5.6 2.1 0 4.1-5.2 6-5.5 28.4-4.6 35-41.7 29.9-55.6-5.4-14.6-13.6-23.5-13.6-23.5z"/></g><g fill="#FFF" opacity=".5"><path d="M44.6 98.9h22.3v24.4H44.6zM0 98.9h22.3v24.4H0zM89.2 98.9h22.3v24.4H89.2zM133.8 98.9h22.3v24.4h-22.3zM178.4 98.9h22.3v24.4h-22.3zM223 98.9h22.3v24.4H223zM267.7 98.9H290v24.4h-22.3zM312.3 98.9h22.3v24.4h-22.3zM356.9 98.9h22.3v24.4h-22.3zM401.5 98.9h22.3v24.4h-22.3zM446.1 98.9h22.3v24.4h-22.3zM490.7 98.9H513v24.4h-22.3zM44.6 216.9h22.3v25.5H44.6zM0 216.9h22.3v25.5H0zM89.2 216.9h22.3v25.5H89.2zM133.8 216.9h22.3v25.5h-22.3zM178.4 216.9h22.3v25.5h-22.3zM223 216.9h22.3v25.5H223zM267.7 216.9H290v25.5h-22.3zM312.3 216.9h22.3v25.5h-22.3zM356.9 216.9h22.3v25.5h-22.3zM401.5 216.9h22.3v25.5h-22.3zM446.1 216.9h22.3v25.5h-22.3zM490.7 216.9H513v25.5h-22.3z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#0052B4" d="M0 0h513v342H0z"/><path fill="#FFF" d="M513 210.6H202.2v130.7H122.4V210.6H0V130.8h122.4V0H202.2v130.8H513v18.5l-1 42.7z"/><path fill="#D80027" d="M513 149.3V192H183.7v149.3H141V192H0v-42.7h141V0h42.7v149.3z"/></svg>

After

Width:  |  Height:  |  Size: 301 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#F4F5F0" d="M342 0H0v341.3h512V0z"/><path fill="#008C45" d="M0 0h171v342H0z"/><path fill="#CD212A" d="M342 0h171v342H342z"/></svg>

After

Width:  |  Height:  |  Size: 204 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h512v342H0z"/><circle fill="#D80027" cx="256.5" cy="171" r="96"/></svg>

After

Width:  |  Height:  |  Size: 158 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v342H0z"/><path d="M0 0h512v90.579H0z"/><path fill="#496E2D" d="M0 251h513v91H0z"/><path fill="#A2001D" d="M0 114h513v114H0z"/><g fill="#FFF"><path d="m323.3 54.5-25.6-11.8L256 137l-41.7-94.3-25.6 11.8 51.8 116.2-51.8 116.2 25.6 11.8 41.7-94.4 41.7 94.4 25.6-11.8-51.8-116.2z"/><path d="M273.4 65.6c-9.9-10.8-17.4-17-17.4-17s-7.5 6.2-17.4 17v210.1c9.9 10.8 17.4 17 17.4 17s7.5-6.2 17.4-17V65.6z"/></g><g fill="#A2001D"><path d="M209 105.9v129.5c10.5 18.5 23.3 33.7 32.9 43.8V62.1c-9.6 10.1-22.4 25.3-32.9 43.8zM303 105.9c-10.5-18.5-23.3-33.7-32.9-43.8v217.2c9.6-10.1 22.4-25.3 32.9-43.8V105.9z"/></g><path d="M303 105.9v129.5c10.6-18.8 18.8-41 18.8-64.8s-8.2-45.9-18.8-64.7zM209 105.9v129.5c-10.6-18.8-18.8-41-18.8-64.8s8.2-45.9 18.8-64.7z"/></svg>

After

Width:  |  Height:  |  Size: 839 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v342H0z"/><g stroke="#000" stroke-width="14.25"><path d="M84.6 107.8 132 36.6m-29.7 83 47.4-71.1m-29.6 83 47.4-71.1M345.4 281.7l47.4-71.1m-29.6 82.9 47.4-71.1m-29.6 83 47.4-71.1M132 305.4l-47.4-71.1m65.2 59.2-47.4-71.1m65.2 59.3-47.4-71.1M392.8 131.5l-47.4-71.1m65.2 59.2-47.4-71.1m65.2 59.3L381 36.6"/></g><g stroke="#FFF" stroke-width="12.5"><path d="m357.3 238.2 59.3 39.5M117.2 263.9 135 252m222.3-148.2L378 90m17.8-11.9 17.8-11.9"/></g><circle fill="#CA163A" cx="256.5" cy="171" r="85.5"/><path fill="#0E4896" d="M185.3 123.6c-13.1 19.6-7.8 46.2 11.9 59.3s46.2 7.8 59.3-11.9 39.6-24.9 59.3-11.9c19.6 13.1 24.9 39.6 11.9 59.3-26.2 39.3-79.3 49.9-118.6 23.7s-49.9-79.3-23.8-118.5z"/></svg>

After

Width:  |  Height:  |  Size: 783 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#36B6CC" d="M0 0h513v342H0V0z"/><circle fill="#FFD400" cx="256.5" cy="170.9" r="68.4"/><path fill="#FFD400" d="m256.5 251.5-27.9 41.7-7.1-49.7-43.2 25.5 15.2-47.8-50 4.2 34.4-36.5-46.9-18 46.8-18-34.4-36.5 50 4.2-15.2-47.8 43.2 25.4 7.1-49.7 27.9 41.7 27.9-41.7 7.1 49.7 43.2-25.4-15.2 47.8 50-4.2-34.3 36.6 46.8 17.9-46.8 18 34.4 36.5-50-4.2 15.2 47.8-43.2-25.4-7.1 49.7c0-.1-27.9-41.8-27.9-41.8zm0-.8c44.1 0 79.9-35.8 79.9-79.9S300.6 91 256.5 91s-79.9 35.8-79.9 79.9 35.8 79.8 79.9 79.8zM22.8 28.3c0-3.2 2.4-5.7 5.7-5.7h11.3c3.2 0 5.7 2.4 5.7 5.7v11.3c.1 3.1-2.4 5.7-5.6 5.7H28.5c-3.1 0-5.7-2.5-5.7-5.6V28.3zm0 45.7c0-3.2 2.4-5.7 5.7-5.7h11.3c3.2 0 5.7 2.4 5.7 5.7v11.3c.1 3.1-2.4 5.7-5.6 5.7H28.5c-3.1 0-5.7-2.5-5.7-5.6V74zm0 45.6c0-3.2 2.4-5.7 5.7-5.7h11.3c3.2 0 5.7 2.4 5.7 5.7v11.3c.1 3.1-2.4 5.7-5.6 5.7H28.5c-3.1 0-5.7-2.5-5.7-5.6v-11.4zm0 45.6c0-3.2 2.4-5.7 5.7-5.7h11.3c3.2 0 5.7 2.4 5.7 5.7v11.3c.1 3.1-2.4 5.7-5.6 5.7H28.5c-3.1 0-5.7-2.5-5.7-5.6v-11.4zm0 45.7c0-3.2 2.4-5.7 5.7-5.7h11.3c3.2 0 5.7 2.4 5.7 5.7v11.3c.1 3.1-2.4 5.7-5.6 5.7H28.5c-3.1 0-5.7-2.5-5.7-5.6v-11.4zm0 45.6c0-3.2 2.4-5.7 5.7-5.7h11.3c3.2 0 5.7 2.4 5.7 5.7v11.3c.1 3.1-2.4 5.7-5.6 5.7H28.5c-3.1 0-5.7-2.5-5.7-5.6v-11.4zm0 45.6c0-3.2 2.4-5.7 5.7-5.7h11.3c3.2 0 5.7 2.4 5.7 5.7v11.3c.1 3.1-2.4 5.7-5.6 5.7H28.5c-3.1 0-5.7-2.5-5.7-5.6v-11.4zm22.8-22.8c0-3.2 2.4-5.7 5.7-5.7h11.3c3.2 0 5.7 2.4 5.7 5.7v11.3c.1 3.1-2.4 5.7-5.6 5.7H51.3c-3.1 0-5.7-2.5-5.7-5.6v-11.4zm0-45.6c0-3.2 2.4-5.7 5.7-5.7h11.3c3.2 0 5.7 2.4 5.7 5.7V245c.1 3.1-2.4 5.7-5.6 5.7H51.3c-3.1 0-5.7-2.5-5.7-5.6v-11.4zm0-45.7c0-3.2 2.4-5.7 5.7-5.7h11.3c3.2 0 5.7 2.4 5.7 5.7v11.3c.1 3.1-2.4 5.7-5.6 5.7H51.3c-3.1 0-5.7-2.5-5.7-5.6V188zm0-45.6c0-3.2 2.4-5.7 5.7-5.7h11.3c3.2 0 5.7 2.4 5.7 5.7v11.3c.1 3.1-2.4 5.7-5.6 5.7H51.3c-3.1 0-5.7-2.5-5.7-5.6v-11.4zm0-45.6c0-3.2 2.4-5.7 5.7-5.7h11.3c3.2 0 5.7 2.4 5.7 5.7v11.3c.1 3.1-2.4 5.7-5.6 5.7H51.3c-3.1 0-5.7-2.5-5.7-5.6V96.8zm0-45.6c0-3.2 2.4-5.7 5.7-5.7h11.3c3.2 0 5.7 2.4 5.7 5.7v11.3c.1 3.1-2.4 5.7-5.6 5.7H51.3c-3.1 0-5.7-2.5-5.7-5.6V51.2z"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#6DA544" d="M0 0h513v342H0z"/><path fill="#FFDA44" d="M0 0h513v114H0z"/><path fill="#D80027" d="M0 228h513v114H0z"/></svg>

After

Width:  |  Height:  |  Size: 196 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v342H0z"/><path fill="#D80027" d="M0 0h513v114H0z"/><path fill="#338AF3" d="M0 228h513v114H0z"/></svg>

After

Width:  |  Height:  |  Size: 193 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v342H0z"/><g fill="#A2001D"><path class="st1" d="M0 0h513v127.6H0zM0 214.4h513V342H0z"/></g></svg>

After

Width:  |  Height:  |  Size: 189 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFDA44" d="M0 0h513v342H0z"/><g fill="#D80027"><path d="M384.6 342 256.5 167.3 283.4 342zM133.8 0l122.7 167.3L230.8 0zM229.6 342l26.9-174.7L128.4 342zM0 0v127.8l256.5 39.5L28.4 0zM0 342h18.2l238.3-174.7L0 206.7zM256.5 167.3 513 127.8V0h-28.4zM282.2 0l-25.7 167.3L379.2 0z"/><path d="M513 342V206.7l-256.5-39.4L494.8 342z"/><circle cx="256.5" cy="171" r="59.5"/></g><circle fill="#FFDA44" cx="256.5" cy="171" r="44.5"/></svg>

After

Width:  |  Height:  |  Size: 499 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#0066b2" d="M0 0h513v342H0z"/><g fill="#da2031"><path d="M342 0h171v342H342zM0 0h171v342H0z"/></g><g fill="#ffd300"><path d="M108.3 166.3h14.8v74.2h-14.8zM48.9 166.3h14.8v74.2H48.9z"/><circle cx="86" cy="203.4" r="14.8"/><path d="M71.2 225.7h29.7v14.8H71.2zM71.2 166.3h29.7v14.8H71.2z"/><circle cx="86" cy="144" r="14.8"/><path d="M76.3 123.9h19.5L86 108.3z"/></g></svg>

After

Width:  |  Height:  |  Size: 444 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v342H0z"/><path fill="#cf142b" d="M256 0h256.5v342H256z"/><path stroke="#cf142b" stroke-width="10" d="M101.2 68.2V33H66v35.2H30.8v35.2H66v35.2h35.2v-35.2h35.2V68.2z"/><path fill="#fff" stroke="#acacac" stroke-width="5" d="M101.2 68.2V33H66v35.2H30.8v35.2H66v35.2h35.2v-35.2h35.2V68.2z"/><circle cx="85" cy="85" r="26" fill="none" stroke="#acacac" stroke-width="5"/><circle cx="85" cy="85" r="18" fill="#dedede" stroke="#acacac" stroke-width="5"/></svg>

After

Width:  |  Height:  |  Size: 543 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#dc2339" d="M342 0H0v341.3h513V0z"/><path fill="#11865d" d="M0 0h171v342H0z"/><path fill="#FFF" d="M171 0h171v342H171z"/><path fill="#8C9157" d="M195.8 171.2c0 21.6 11.5 41.7 30.3 52.5 5.8 3.4 13.2 1.4 16.6-4.4 3.4-5.8 1.4-13.2-4.4-16.6-11.3-6.5-18.2-18.5-18.2-31.5 0-6.7-5.4-12.1-12.1-12.1-6.7 0-12.2 5.4-12.2 12.1zm93.4 51.1c17.5-11.1 28-30.4 28-51.1 0-6.7-5.4-12.1-12.1-12.1s-12.1 5.4-12.1 12.1c0 12.4-6.3 24-16.8 30.7-5.7 3.5-7.5 10.9-4.1 16.7s10.9 7.5 16.7 4.1c0-.2.2-.3.4-.4z"/><ellipse fill="#C59262" cx="256.5" cy="159.1" rx="24.3" ry="36.4"/></svg>

After

Width:  |  Height:  |  Size: 631 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v342H0z"/><g fill="#c00"><path d="M0 0h513v24.4H0zM0 48.9h513v24.4H0zM0 97.7h513v24.4H0zM0 146.6h513V171H0zM0 195.4h513v24.4H0zM0 244.3h513v24.4H0zM0 293.1h513v24.4H0z"/></g><path fill="#006" d="M0 0h256.5v195.4H0z"/><path d="M153.3 42.1C122.6 30.7 88.5 46.3 77.1 77s4.2 64.8 34.9 76.2c13.3 5 28 5 41.4 0-30.7 24.5-75.4 19.6-100-11.1s-19.6-75.4 11.1-100c26-20.7 62.9-20.7 88.8 0zm26.7 75-20.6 23.3 5.4-30.6-31-1.6 27.3-14.9L143 68l28.6 12 8.4-29.9 8.4 29.9L217 68l-18 25.4 27.3 14.9-31 1.6 5.4 30.6-20.7-23.4z" fill="#fc0"/></svg>

After

Width:  |  Height:  |  Size: 621 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 114h513v114H0z"/><path fill="#cd1f2a" d="M0 0h513v114H0z"/><path fill="#1d4185" d="M0 228h513v114H0z"/></svg>

After

Width:  |  Height:  |  Size: 195 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#ba0c2f" d="M0 0h513v342H0z"/><path fill="#FFF" d="M513 211H202.6v131h-79.9V211H0v-80h122.7V0h79.9v131H513v61.4z"/><path fill="#00205b" d="M513 149.6v42.8H184V342h-42.7V192.4H0v-42.8h141.3V0H184v149.6z"/></svg>

After

Width:  |  Height:  |  Size: 284 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v342H0z"/><path fill="#0038A8" d="M513 0v171H256.5L0 0z"/><path fill="#CE1126" d="M513 171v171H0l256.5-171z"/><g fill="#FCD116"><path d="m22.1 33.8 8.5 11.9 13.8-4.5-8.6 11.7 8.5 11.8-13.8-4.6-8.6 11.7V57.2L8.2 52.7 22 48.4zM22.1 270.2l8.5 11.8 13.8-4.4-8.6 11.7 8.5 11.8-13.8-4.6-8.6 11.7v-14.6l-13.7-4.5 13.8-4.3zM210.5 152l-8.5 11.8-13.8-4.4 8.6 11.7-8.4 11.8 13.7-4.6 8.6 11.7v-14.6l13.7-4.5-13.8-4.3z"/><circle cx="95" cy="171" r="32"/><path d="m95 171 11.2 56.5-3.4 3.4-3.7 3.5-4.2 4.2-4.2-4.2-3.7-3.5-3.4-3.4L95 171zM95 171l-11.2-56.5 3.4-3.4 3.7-3.5 4.2-4.2 4.2 4.2 3.7 3.5 3.4 3.4L95 171zM95 171l56.5-11.2 3.4 3.4 3.5 3.7 4.2 4.2-4.2 4.2-3.5 3.7-3.4 3.4L95 171zM95 171l-56.5 11.2-3.4-3.4-3.5-3.7-4.2-4.2 4.2-4.2 3.5-3.7 3.4-3.4L95 171zM95 171l32-47.9h4.8l5.1.2h5.9v5.9l.2 5.1v4.8L95 171zM95 171l-32 47.9h-4.8l-5.1-.2h-5.9v-5.9l-.2-5.1v-4.8L95 171zM95 171l47.9 32v4.8l-.2 5.1v5.9h-5.9l-5.1.2h-4.8L95 171zM95 171l-47.9-32v-4.8l.2-5.1v-5.9h5.9l5.1-.2h4.8L95 171z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 600"><path fill="#FFF" d="M0 0h900v600H0z"/><path fill="#01411C" d="M225 0h675v600H225z"/><path fill="#FFF" d="M562.5 120c99.4 0 180 80.6 180 180s-80.6 180-180 180-180-80.6-180-180 80.6-180 180-180z"/><path fill="#01411C" d="M498.9 135.8c68.1-60.5 172.4-54.4 232.9 13.7s54.4 172.4-13.7 232.9-172.4 54.4-232.9-13.7-54.4-172.4 13.7-232.9z"/><path fill="#FFF" d="m628.1 165.3 75.8 85.3-111.5-24.4L697 180.4l-57.7 98.5-11.2-113.6z"/></svg>

After

Width:  |  Height:  |  Size: 492 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v342H0z"/><path fill="#dc143c" d="M0 171h513v171H0z"/></svg>

After

Width:  |  Height:  |  Size: 151 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="red" d="M0 0h513v342H0z"/><path fill="#060" d="M197 0v342H0V0z"/><circle fill="#FF0" cx="197.1" cy="171" r="64"/><path fill="red" d="M161.1 139v40c0 19.9 16.1 36 36 36s36-16.1 36-36v-40h-72z"/><path fill="#FFF" d="M197.1 191c-6.6 0-12-5.4-12-12v-16h24v16c0 6.6-5.3 12-12 12z"/></svg>

After

Width:  |  Height:  |  Size: 357 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#002b7f" d="M0 0h171v342H0z"/><path fill="#fcd116" d="M171 0h171v342H171z"/><path fill="#ce1126" d="M342 0h171v342H342z"/></svg>

After

Width:  |  Height:  |  Size: 202 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#c6363c" d="M0 0h513v114H0z"/><path fill="#0c4076" d="M0 114h513v114H0z"/><path fill="#FFF" d="M0 228h513v114H0z"/><path fill="#c6363c" d="M120 96.8v85.1c0 48.4 63.3 63.3 63.3 63.3s63.3-14.8 63.3-63.3V96.8H120z"/><g fill="#edb92e"><path d="M146.1 111.6h74.2V82l-14.8 7.4-22.3-22.3L161 89.4 146.1 82zM231.7 193.8l-48.3-48.2-48.2 48.2 15.7 15.7 32.5-32.5 32.5 32.5z"/></g><path fill="#FFF" d="M231.7 148.7h-22.5c1.3-2.2 2-4.7 2-7.4 0-8.2-6.6-14.8-14.8-14.8-5.7 0-10.6 3.2-13.1 7.8-2.5-4.7-7.4-7.8-13.1-7.8-8.2 0-14.8 6.6-14.8 14.8 0 2.7.7 5.2 2 7.4h-22.1c0 8.2 7.1 14.8 15.3 14.8h-.6c0 8.2 6.6 14.8 14.8 14.8 0 7.3 5.2 13.3 12.1 14.6l-11.7 26.4c5.6 2.3 11.7 3.5 18.1 3.5s12.5-1.3 18.1-3.5L189.9 193c6.9-1.3 12.1-7.3 12.1-14.6 8.2 0 14.8-6.6 14.8-14.8h-.5c8.2 0 15.4-6.7 15.4-14.9z"/></svg>

After

Width:  |  Height:  |  Size: 861 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v114H0z"/><path fill="#0039a6" d="M0 114h513v114H0z"/><path fill="#d52b1e" d="M0 228h513v114H0z"/></svg>

After

Width:  |  Height:  |  Size: 195 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path d="M0 0h513v342H0z" fill="#fff"/><g fill="#00512e"><path d="M0 0v342h513V0zm218 76q1 2-2 5-3 2-1 4 3 4 0 6-2 1-5-1-6-6 0-14 6-4 8 0m-102 2 1 6q0 4-2 4-5 1-5-6l2-4zm39 1 1 2q-1 4-5 4-5 0-6-4c0-4 7-6 10-2m47 2q3 4 2 9c-1 2-22 19-24 19q-3 0-4-4l6-6 6-5v-3c-5-10 7-18 14-10m35-3q3 1 0 7-2 8-6 6c-3-1-3 14 0 19q2 4 7-1c2-3 2-5 2-15 0-12 0-13 2-13q5-2 5 13 1 11 3 13 3 6 6 3 3-2-1-12-3-12 1-16h9q2-2 9 1l6 2 1-2q4-4 6-1l3 17c3 16 3 16 7 24l15 30q1 6-4 5l-8-14-6-12 1 20c1 21 1 23-4 23q-4 2-4-15l-1-27-1-16-9-13c-8-12-8-13-11-13q-6-1-2 6l2 7q0 4 3 2 6-2 6 3-1 4-8 5l-5 2-5 2q-6 0-10-3-2-3-5 1-5 5-12 2-5-1-7-10l-1-3-1 3q-3 7-9 8-7 0-9-11-3-10 2-10 4-1 5 7l2 6q1 0 4-7 6-17 13-12h1c-1-1 2-10 4-11zm-97 10 2 16 1 9h4q5 2 6-1c2-1 2-2-1-11l-3-12 3-1 11 5V82q4-2 6 1l3 23 1 20h2q5 0 5 5 1 6 3 0 3-9 8-2 1 3 2 0 3-7 4-6c5 0 6 7 1 13q-3 3-7 3l-6 1q-5 4-10 0l-2-2v5c0 21-16 42-33 42q-21-1-23-21 0-5 2-6 4-1 6 6 1 12 12 13 14 2 24-15 6-11 1-5-12 18-28 11-7-5-9-17-2-6 1-7 5-3 6 6 1 12 10 12 7 0 13-8 5-8-1-7-11-1-8-12 8-11 17-5l3 2-1-12-4-22q-3 1 0 7c2 6 2 6 0 10l-5 5q-3 3-7 1h-5l-1 16c-1 19-1 20-4 21-4 1-4-2-4-19l1-15-3 1q-3 1-6-2c-2-2-2-2-3-14l-1-12q-2-1-2 10l-1 13q-3 6-8 4c-3-2-4-4-7-15q-4-15 2-14 4-1 5 8l1 5 1-6q0-13 9-12l6 2c2 3 2 4 2 14l1 11 2-1 1-8-2-16q-3-12 2-11 3-1 5 7m163-3q4 10 1 13-4 3-7-7-3-11 2-10zm12-3 3 7q2 6 1 8v16l2 27c2 22 2 21 6 21h3l-1-5-2-23-3-33c-2-16-1-19 3-19 3 0 8 14 6 16v24c5 47 5 48 0 47q-3-2-1 4l2 6q2 1 5-9 7-18 10-16 3 0 7 5 4 7 7 7 4-1 1-33l-1-21-10-12-9-15q0-5 8-1 5 2 6 6l2 6q3 4 1-4 0-10 3-10c4 0 9 10 7 13l-1 8q1 8 4 12l3 3-2-15c-2-16-2-19 1-20q3 0 5 4l4 5h2q6-4 8 9 0 8-4 8-3 0-4-6 1-8-3-5c-1 2-1 3 1 19l2 17 5 7c6 10 12 21 11 22q-2 3-5 2l-5-8-5-8 1 8c0 11 0 11-3 11q-5 3-5-7l-1-16c0-8-1-10-4-15l-4-5v19c1 21 0 25-5 28q-6 5-13-3l-4-5-4 9q-6 14-11 15-9-1-11-16-1-8-5-1c-3 5-12 13-19 17q-8 4-8-2-1-2 6-6c9-5 17-14 17-18l-5-78q2-4 5-1m89 5 4 6q1 1-2 3l-2 2 4 24q4 25 4 35c0 10 0 11-3 17q-7 13-19 14-8 0-7-2-2-4 7-6 15-2 14-23a708 708 0 0 0-8-75c3-2 3-1 8 5m-211 19 1 4q0 3 2 2 5 0 5 4c0 4-9 6-13 2-4-5 0-17 5-12m108 1h2l4-1q4 2 0 7-7 6-11-1v-5q3-2 5 0m55 2q3 4-1 6-2 0-1 5t-1 6q-1 2-10-6-8-7-6-9 3-4 7 0l2 2 2-3q5-3 8-1m-131 13c3 2 6 14 6 17q-1 6 6 1l10-4q5-1 7-4 4-9 10-12 10 0 11 9 0 7-16 13l-2 1 13 1c16 0 16 0 13 9q-2 7-4 7l-10 2q-19 3-17 9 1 2 4 3 6 3 3 7l-7 1q-13-2-21-10l-3-3-2 3q-7 10-19 13-10 2-15-10l-5 2q-18 10-19 8l-2-3c0-2 1-2 15-11l9-6q1-3 5-4c3 1 10-5 12-9q3-6-1-15l-2-8q1-5 6-6c3 0 8 5 8 7q-1 4-4 3v1l1 10c1 9-1 13-10 21l-7 6 2 3q5 9 15-2 10-8 10-23 0-10-6-24v-4q3-4 6 1m17 2q2 3 4 0 11-3 5 5c-4 5-12 5-15-1v-4zm118 5 1 2q1 2-7 8c-8 6-9 7-11 5q-5-4 6-10c8-6 8-6 11-5m-229 3q3 2-10 10-12 8-13 3-5-3 9-10 13-8 14-3m229 10c3 2 4 9 3 11q-3 3-6 1c-2-2-3-11-1-13q1-2 4 1m-172 7q3 3 2 7 1 5-3 5-3 1-3-3l-2-4q-3-3-1-6 4-2 7 1m-79 9 1 8q0 9 3 6 4-1 4 3-1 7-7 7-7-1-7-14-1-9 2-10zm168 12q0 4 3 2t4 3q-2 6-8 8l-4-2q-3-3-4-2-4 0-5-4l5-6q7-6 7-4 3 1 2 5m102 3q4 4 1 9-7 7-7-4t6-5m-42 73c0 2 1 2 12 2q24-1 22 11 0 7-6 10c-4 1-31 2-34 0q-2-1-2-6v-4h-82l-74-1c-16 0-28-10-28-10h92c91 0 92-1 92-2q-1-3 4-5c5-2 4 4 4 5"/><path d="m195 86 1 1 1-1-1-1q-2 0-1 1m84 5q-2 2 1 5l3 3-1-4-1-5zm-125 45q-3 3-1 4 5-1 6-4zm110-7q-4 5 0 3 6-4 2-4zm-31 30q0 6 4 8l5 4q3 2 2-3 0-3 4-7 4-3 2-4l-13-3h-5zm104 101q-1 3 13 2c11 0 13 0 14-2l-13-1z"/></g></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#004F8E" d="M0 0h513v342H0z"/><path fill="#F6C500" d="M192.4.3h-64.2v138.8H0v64.1h128.2V342h64.2V203.2H513v-64.1H192.4z"/></svg>

After

Width:  |  Height:  |  Size: 202 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 160"><path fill="#FF0D0B" d="M0 0h240v160H0V0z"/><path fill="#0E55BD" d="M0 0h240v106.7H0V0z"/><path fill="#FFF" d="M0 0h240v53.3H0V0z"/><path fill="#0E55BD" d="M99.5 30.8c-12.4-5.5-26.4-5.5-38.8 0-2.2 28.7 4.4 44.8 19.3 48.7 14.9-3.9 21.5-20 19.5-48.7z"/><g fill="#FF0"><path d="m82 37.1-1.5.1-.5-1.3-.5 1.3-1.5-.1.8 1.2-.8 1.2 1.5-.2.5 1.2.5-1.2 1.5.2-.8-1.2zM76.5 29.7l-1.4.2-.6-1.4-.6 1.4-1.4-.2.8 1.2-.8 1.2 1.4-.1.6 1.3.6-1.3 1.4.1-.8-1.2zM87.5 29.7l-1.4.2-.6-1.4-.6 1.4-1.4-.2.8 1.2-.8 1.2 1.4-.1.6 1.3.6-1.3 1.4.1-.8-1.2z"/></g><path fill="#FFF" d="m87.9 48.7-3.1 4.1-4.7-9.3-4.7 9.3-3.1-4.1-7.9 10.4c0 .5.1.9.3 1.5 2.1 7.6 8 13.7 15.5 16.3 7.5-2.5 13.3-8.7 15.5-16.3.1-.4.3-.9.3-1.5l-8.1-10.4z"/><path fill="#0E55BD" d="M64.5 60.5c.8 2.8 3.7 4.4 6.4 3.6.4-.1.8-.3 1.2-.5 1.6-.9 3.6-.9 5.2 0s3.6.9 5.2 0 3.6-.9 5.2 0c2.5 1.5 5.7.5 7.1-1.9.3-.4.4-.8.5-1.2l.3.1v3.9c-.9 0-1.9.3-2.7.7-1.6.9-3.6.9-5.2 0s-3.6-.9-5.2 0-3.6.9-5.2 0-3.6-.9-5.2 0-3.6.9-5.2 0c-.8-.4-1.7-.7-2.7-.7v-3.9"/><path fill="#0E55BD" d="M64.5 63.6c.8 2.8 3.7 4.4 6.4 3.6.4-.1.8-.3 1.2-.5 1.6-.9 3.6-.9 5.2 0s3.6.9 5.2 0 3.6-.9 5.2 0c2.5 1.5 5.7.5 7.1-1.9.3-.4.4-.8.5-1.2l.3.1v3.9c-.9 0-1.9.3-2.7.7-1.6.9-3.6.9-5.2 0s-3.6-.9-5.2 0-3.6.9-5.2 0-3.6-.9-5.2 0-3.6.9-5.2 0c-.8-.4-1.7-.7-2.7-.7v-3.9"/><path fill="#FF0D0B" d="m99.5 30.8-1.6 24.8c-.7 10.5-7.8 19.7-17.9 22.9-10.1-3.2-17.2-12.3-17.9-22.9l-1.6-24.8c-.5.3-.9.4-1.5.7l1.5 24.1C61.3 66.9 69.1 76.7 80 80c10.9-3.3 18.7-13.1 19.3-24.4l1.5-24.1c-.4-.3-.9-.6-1.3-.7z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 85.333 512 341.333"><path fill="#FFF" d="M0 85.337h512v341.326H0z"/><path fill="#0052B4" d="M0 196.641h512v118.717H0z"/><path fill="#D80027" d="M0 315.359h512v111.304H0z"/><path fill="#FFF" d="M129.468 181.799v85.136c0 48.429 63.267 63.267 63.267 63.267S256 315.362 256 266.935v-85.136H129.468z"/><path fill="#D80027" d="M146.126 184.294v81.941c0 5.472 1.215 10.64 3.623 15.485h85.97c2.408-4.844 3.623-10.012 3.623-15.485v-81.941h-93.216z"/><path fill="#FFF" d="M221.301 241.427h-21.425v-14.283h14.284v-14.283h-14.284v-14.284h-14.283v14.284h-14.282v14.283h14.282v14.283h-21.426v14.284h21.426v14.283h14.283v-14.283h21.425z"/><path fill="#0052B4" d="M169.232 301.658c9.204 5.783 18.66 9.143 23.502 10.636 4.842-1.494 14.298-4.852 23.502-10.636 9.282-5.833 15.79-12.506 19.484-19.939a24.878 24.878 0 0 0-14.418-4.583c-1.956 0-3.856.232-5.682.657-3.871-8.796-12.658-14.94-22.884-14.94-10.227 0-19.013 6.144-22.884 14.94a25.048 25.048 0 0 0-5.682-.657 24.88 24.88 0 0 0-14.418 4.583c3.691 7.433 10.198 14.106 19.48 19.939z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 85.333 512 341.333"><path fill="#FFF" d="M0 85.334h512V426.66H0z"/><path fill="#0052B4" d="M0 194.056h512v123.882H0z"/><g fill="#D80027"><path d="M0 85.334h512v54.522H0zM0 372.143h512v54.522H0z"/></g></svg>

After

Width:  |  Height:  |  Size: 257 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#E30A17" d="M0 0h513v342H0z"/><path fill="#FFF" d="M259.7 118.6c-13.1-9.5-29-14.6-45.3-14.5-40.8 0-73.8 30.8-73.8 68.9s33.1 68.9 73.8 68.9c17.1 0 32.9-5.4 45.3-14.5-30 38.6-85.7 45.6-124.3 15.5s-45.6-85.7-15.5-124.3 85.7-45.6 124.3-15.5c5.8 4.5 11 9.8 15.5 15.5zm39.9 65.8-18.1 21.9 1.2-28.4-26.4-10.4 27.3-7.6 1.8-28.3 15.6 23.7 27.5-7.1-17.5 22 15.3 23.9-26.7-9.7z"/></svg>

After

Width:  |  Height:  |  Size: 449 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 85.333 512 341.333"><path fill="#D80027" d="M0 85.337h512v341.326H0z"/><path fill="#0052B4" d="M0 85.337h256V256H0z"/><path fill="#FFF" d="M186.435 170.669 162.558 181.9l12.714 23.125-25.927-4.961-3.286 26.192L128 206.993l-18.06 19.263-3.285-26.192-25.927 4.96 12.714-23.125-23.877-11.23 23.877-11.231-12.714-23.125 25.927 4.96 3.286-26.192L128 134.344l18.06-19.263 3.285 26.192 25.928-4.96-12.715 23.125z"/><circle fill="#0052B4" cx="128" cy="170.674" r="29.006"/><path fill="#FFF" d="M128 190.06c-10.692 0-19.391-8.7-19.391-19.391 0-10.692 8.7-19.391 19.391-19.391 10.692 0 19.391 8.7 19.391 19.391 0 10.691-8.699 19.391-19.391 19.391z"/></svg>

After

Width:  |  Height:  |  Size: 697 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 85.333 512 341.333"><path fill="#FFDA44" d="M0 85.337h512v341.326H0z"/><path fill="#338AF3" d="M0 85.337h512V256H0z"/></svg>

After

Width:  |  Height:  |  Size: 175 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v342H0z"/><g fill="#D80027"><path d="M0 0h513v26.3H0zM0 52.6h513v26.3H0zM0 105.2h513v26.3H0zM0 157.8h513v26.3H0zM0 210.5h513v26.3H0zM0 263.1h513v26.3H0zM0 315.7h513V342H0z"/></g><path fill="#2E52B2" d="M0 0h256.5v184.1H0z"/><g fill="#FFF"><path d="m47.8 138.9-4-12.8-4.4 12.8H26.2l10.7 7.7-4 12.8 10.9-7.9 10.6 7.9-4.1-12.8 10.9-7.7zM104.1 138.9l-4.1-12.8-4.2 12.8H82.6l10.7 7.7-4 12.8 10.7-7.9 10.8 7.9-4-12.8 10.7-7.7zM160.6 138.9l-4.3-12.8-4 12.8h-13.5l11 7.7-4.2 12.8 10.7-7.9 11 7.9-4.2-12.8 10.7-7.7zM216.8 138.9l-4-12.8-4.2 12.8h-13.3l10.8 7.7-4 12.8 10.7-7.9 10.8 7.9-4.3-12.8 11-7.7zM100 75.3l-4.2 12.8H82.6L93.3 96l-4 12.6 10.7-7.8 10.8 7.8-4-12.6 10.7-7.9h-13.4zM43.8 75.3l-4.4 12.8H26.2L36.9 96l-4 12.6 10.9-7.8 10.6 7.8L50.3 96l10.9-7.9H47.8zM156.3 75.3l-4 12.8h-13.5l11 7.9-4.2 12.6 10.7-7.8 11 7.8-4.2-12.6 10.7-7.9h-13.2zM212.8 75.3l-4.2 12.8h-13.3l10.8 7.9-4 12.6 10.7-7.8 10.8 7.8-4.3-12.6 11-7.9h-13.5zM43.8 24.7l-4.4 12.6H26.2l10.7 7.9-4 12.7L43.8 50l10.6 7.9-4.1-12.7 10.9-7.9H47.8zM100 24.7l-4.2 12.6H82.6l10.7 7.9-4 12.7L100 50l10.8 7.9-4-12.7 10.7-7.9h-13.4zM156.3 24.7l-4 12.6h-13.5l11 7.9-4.2 12.7 10.7-7.9 11 7.9-4.2-12.7 10.7-7.9h-13.2zM212.8 24.7l-4.2 12.6h-13.3l10.8 7.9-4 12.7 10.7-7.9 10.8 7.9-4.3-12.7 11-7.9h-13.5z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#3081f7" d="M0 0h513v108H0z"/><path fill="#ee162e" d="M0 108h513v9H0z"/><path fill="#FFF" d="M0 117h513v108H0z"/><path fill="#ee162e" d="M0 225h513v9H0z"/><path fill="#308738" d="M0 233h513v108H0z"/><g fill="#FFF"><path d="M53.5 54c0-23.1 16.4-35 34.3-38.9-2.7-.6-5.6-.9-8.5-.9-22 0-39.8 17.8-39.8 39.8s17.8 39.8 39.8 39.8c2.9 0 5.8-.3 8.5-.9C69.9 89 53.5 77.1 53.5 54zM111.4 75.3l1.8 5.9h6.2l-5 3.6 1.9 5.8-4.9-3.6-5 3.6 1.9-5.8-4.9-3.6h6.1zM139.2 75.3l1.9 5.9h6.1l-5 3.6 1.9 5.8-4.9-3.6-5 3.6 1.9-5.8-4.9-3.6h6.1zM167 75.3l1.9 5.9h6.1l-4.9 3.6 1.9 5.8-5-3.6-4.9 3.6 1.9-5.8-5-3.6h6.1zM194.9 75.3l1.9 5.9h6.1l-5 3.6 1.9 5.8-4.9-3.6-5 3.6 1.9-5.8-4.9-3.6h6.1zM222.7 75.3l1.9 5.9h6.1l-4.9 3.6 1.9 5.8-5-3.6-4.9 3.6 1.9-5.8-5-3.6h6.1zM139.2 46.4l1.9 5.8h6.1l-5 3.6 1.9 5.8-4.9-3.6-5 3.6 1.9-5.8-4.9-3.6h6.1zM167 46.4l1.9 5.8h6.1l-4.9 3.6 1.9 5.8-5-3.6-4.9 3.6 1.9-5.8-5-3.6h6.1zM194.9 46.4l1.9 5.8h6.1l-5 3.6 1.9 5.8-4.9-3.6-5 3.6 1.9-5.8-4.9-3.6h6.1zM222.7 46.4l1.9 5.8h6.1l-4.9 3.6 1.9 5.8-5-3.6-4.9 3.6 1.9-5.8-5-3.6h6.1zM167 17.4l1.9 5.8h6.1l-4.9 3.6 1.9 5.9-5-3.6-4.9 3.6 1.9-5.9-5-3.6h6.1zM194.9 17.4l1.9 5.8h6.1l-5 3.6 1.9 5.9-4.9-3.6-5 3.6 1.9-5.9-4.9-3.6h6.1zM222.7 17.4l1.9 5.8h6.1l-4.9 3.6 1.9 5.9-5-3.6-4.9 3.6 1.9-5.9-5-3.6h6.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 85.333 512 341.333"><path fill="#D80027" d="M196.641 85.337H0v341.326h512V85.337z"/><path fill="#FFDA44" d="m256 157.279 22.663 69.747H352l-59.332 43.106 22.664 69.749L256 296.774l-59.332 43.107 22.664-69.749L160 227.026h73.337z"/></svg>

After

Width:  |  Height:  |  Size: 288 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 85.333 512 341.333"><path fill="#FFF" d="M0 85.337h512v341.326H0z"/><path d="M114.024 256.001 0 141.926v228.17z"/><path fill="#ffb915" d="M161.192 256 0 94.7v47.226l114.024 114.075L0 370.096v47.138z"/><path fill="#007847" d="M509.833 289.391c.058-.44.804-.878 2.167-1.318v-65.464H222.602L85.33 85.337H0V94.7L161.192 256 0 417.234v9.429h85.33l137.272-137.272h287.231z"/><path fill="#000c8a" d="M503.181 322.783H236.433l-103.881 103.88H512v-103.88z"/><path fill="#e1392d" d="M503.181 189.217H512V85.337H132.552l103.881 103.88z"/></svg>

After

Width:  |  Height:  |  Size: 584 B

+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>;
}
+39
View File
@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { build } from "esbuild";
const out = await build({ entryPoints: [fileURLToPath(new URL("../src/ui/language-catalog.ts", import.meta.url))],
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
const { languageCatalog, searchLanguages, describeLanguage, BCP47 } =
await import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString("base64")}`);
const all = languageCatalog();
const codes = (text) => searchLanguages(all, text).map((o) => o.code);
test("a language is found by its Russian name, its own name, English name or code", () => {
assert.equal(codes("немец")[0], "de");
assert.equal(codes("deutsch")[0], "de");
assert.equal(codes("german")[0], "de");
assert.equal(codes("ja")[0], "ja");
assert.ok(codes("португал").includes("pt-BR"));
});
test("names that begin with the text come before names that merely contain it", () => {
const found = codes("но"); // «Норвежский» starts with it; «Македонский» or «Иврит»-like names only contain it
assert.equal(found[0], "no");
assert.ok(found.length > 1, "words that only contain the text are still offered");
});
test("empty search shows everything, nonsense shows nothing", () => {
assert.equal(searchLanguages(all, " ").length, all.length);
assert.deepEqual(codes("qqqqzzz"), []);
});
test("every flag the catalog points to exists, and every offered code is valid BCP-47", () => {
for (const o of all) {
assert.match(o.code, BCP47, o.code);
if (o.flag) assert.ok(existsSync(fileURLToPath(new URL(`../public/flags/${o.flag}.svg`, import.meta.url))), `${o.code} -> ${o.flag}`);
}
assert.equal(describeLanguage("ru").name, "Русский");
});
+25
View File
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { build } from "esbuild";
const out = await build({ entryPoints: [fileURLToPath(new URL("../src/ui/paging.ts", import.meta.url))],
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
const { pageNumbers } = await import(`data:text/javascript;base64,${Buffer.from(out.outputFiles[0].text).toString("base64")}`);
test("few pages are all listed", () => {
assert.deepEqual(pageNumbers(0, 1), [0]);
assert.deepEqual(pageNumbers(2, 5), [0, 1, 2, 3, 4]);
assert.deepEqual(pageNumbers(0, 7), [0, 1, 2, null, 6]);
});
test("a long list keeps the ends and the neighbourhood, with gaps between", () => {
assert.deepEqual(pageNumbers(0, 100), [0, 1, 2, null, 99]);
assert.deepEqual(pageNumbers(50, 100), [0, null, 48, 49, 50, 51, 52, null, 99]);
assert.deepEqual(pageNumbers(99, 100), [0, null, 97, 98, 99]);
});
test("a gap of exactly one page shows that page instead of dots", () => {
assert.deepEqual(pageNumbers(4, 10), [0, 1, 2, 3, 4, 5, 6, null, 9]);
assert.deepEqual(pageNumbers(3, 10), [0, 1, 2, 3, 4, 5, null, 9]);
});