Localization stages 2-3: drafts, publishing, translation editor

- games.main_version_id, version visibility (draft/published) and kind
  (original/translation/mod), version_history; migration 0002
- permissions translations.edit and versions.publish
- "Translate" creates a translation draft; publishing merges the changed
  languages into the main version in place (players keep their saves), the
  previous manifest goes to the history and can be restored
- translation API: overview with per-state stats, entries with filters,
  search and scene context, save, glossary, locale font, PO/CSV export/import
- translation editor page: languages and progress, lines and strings,
  filters, autosave, approve/outdated/fuzzy actions, glossary highlighting,
  "play from here" with the scene restored
- player: ?lang= and ?at= from the editor, per-locale font

(models.py and permissions.py for this landed earlier in 9ee3e1f)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-25 03:56:19 +03:00
1 parent 4e047f3aa0
commit ac164d1782
20 files changed
+2039 -298

No files matched your search

+2
View File
@@ -8,6 +8,7 @@ import { Login } from "./pages/Login";
import { NewGame } from "./pages/NewGame";
import { Play } from "./pages/Play";
import { Roles } from "./pages/Roles";
import { TranslationEditor } from "./pages/TranslationEditor";
import { Users } from "./pages/Users";
import { useTheme } from "./theme";
import { Icon } from "./ui/Icon";
@@ -108,6 +109,7 @@ export function App() {
<Route path="/" element={<RequirePerm perm="games.play"><Library /></RequirePerm>} />
<Route path="/games/new" element={<RequirePerm perm="games.upload"><NewGame /></RequirePerm>} />
<Route path="/games/:gameId" element={<RequirePerm perm="games.play"><GamePage /></RequirePerm>} />
<Route path="/versions/:versionId/translate" element={<RequirePerm perm="games.play"><TranslationEditor /></RequirePerm>} />
<Route path="/admin/users" element={<RequirePerm perm="users.manage"><Users /></RequirePerm>} />
<Route path="/admin/roles" element={<RequirePerm perm="roles.manage"><Roles /></RequirePerm>} />
<Route path="/account" element={<ChangeCredentials />} />
+128 -123
View File
@@ -1,123 +1,128 @@
export interface Role {
id: number;
name: string;
permissions: string[];
is_system: boolean;
}
export interface User {
id: number;
username: string;
role: Role;
is_active: boolean;
must_change_password: boolean;
must_change_username: boolean;
created_at: string;
}
export interface Me extends User {
permissions: string[];
}
export interface Version {
id: number;
game_id: number;
parent_id: number | null;
owner: { id: number; username: string } | null;
name: string;
description: string;
status: "uploading" | "queued" | "converting" | "importing" | "ready" | "failed";
progress: string;
size_bytes: number;
created_at: string;
report?: Record<string, any> | null;
}
export interface Game {
id: number;
title: string;
description: string;
tags: string[];
engine: string;
cover_hash: string | null;
created_at: string;
ready_versions?: number;
pending_versions?: number;
failed_versions?: number;
}
export interface GameDetail extends Game {
versions: Version[];
}
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
async function request<T>(method: string, url: string, body?: unknown): Promise<T> {
const r = await fetch(url, {
method,
credentials: "same-origin",
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!r.ok) {
let msg = r.statusText;
try {
const data = await r.json();
msg = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail);
} catch {
/* not json */
}
if (r.status === 401) window.dispatchEvent(new Event("pg-unauthorized"));
throw new ApiError(r.status, msg);
}
return r.status === 204 ? (undefined as T) : r.json();
}
export const api = {
get: <T>(url: string) => request<T>("GET", url),
post: <T>(url: string, body?: unknown) => request<T>("POST", url, body ?? {}),
put: <T>(url: string, body?: unknown) => request<T>("PUT", url, body ?? {}),
patch: <T>(url: string, body?: unknown) => request<T>("PATCH", url, body ?? {}),
del: (url: string) => request<void>("DELETE", url),
};
/** Upload a raw body with progress (fetch has no upload progress). */
export function uploadRaw<T>(method: string, url: string, file: Blob,
onProgress: (fraction: number) => void): Promise<T> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total);
xhr.onload = () => {
let data: any = null;
try {
data = JSON.parse(xhr.responseText);
} catch {
/* empty */
}
if (xhr.status >= 200 && xhr.status < 300) resolve(data);
else if (xhr.status === 413 && !data?.detail)
reject(new ApiError(413, "Файл слишком большой для прокси перед сервером (client_max_body_size)"));
else reject(new ApiError(xhr.status, data?.detail ?? (xhr.statusText || `Ошибка ${xhr.status}`)));
};
xhr.onerror = () => reject(new ApiError(0, "Сетевая ошибка"));
xhr.send(file);
});
}
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Б`;
const units = ["КБ", "МБ", "ГБ", "ТБ"];
let v = bytes / 1024;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`;
}
export interface Role {
id: number;
name: string;
permissions: string[];
is_system: boolean;
}
export interface User {
id: number;
username: string;
role: Role;
is_active: boolean;
must_change_password: boolean;
must_change_username: boolean;
created_at: string;
}
export interface Me extends User {
permissions: string[];
}
export interface Version {
id: number;
game_id: number;
parent_id: number | null;
owner: { id: number; username: string } | null;
name: string;
description: string;
status: "uploading" | "queued" | "converting" | "importing" | "ready" | "failed";
visibility: "draft" | "published";
kind: "original" | "translation" | "mod";
source_lang: string | null;
languages: { code: string; name: string; progress: number }[];
progress: string;
size_bytes: number;
created_at: string;
report?: Record<string, any> | null;
}
export interface Game {
id: number;
title: string;
description: string;
tags: string[];
engine: string;
cover_hash: string | null;
main_version_id: number | null;
created_at: string;
ready_versions?: number;
pending_versions?: number;
failed_versions?: number;
}
export interface GameDetail extends Game {
versions: Version[];
}
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
async function request<T>(method: string, url: string, body?: unknown): Promise<T> {
const r = await fetch(url, {
method,
credentials: "same-origin",
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!r.ok) {
let msg = r.statusText;
try {
const data = await r.json();
msg = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail);
} catch {
/* not json */
}
if (r.status === 401) window.dispatchEvent(new Event("pg-unauthorized"));
throw new ApiError(r.status, msg);
}
return r.status === 204 ? (undefined as T) : r.json();
}
export const api = {
get: <T>(url: string) => request<T>("GET", url),
post: <T>(url: string, body?: unknown) => request<T>("POST", url, body ?? {}),
put: <T>(url: string, body?: unknown) => request<T>("PUT", url, body ?? {}),
patch: <T>(url: string, body?: unknown) => request<T>("PATCH", url, body ?? {}),
del: (url: string) => request<void>("DELETE", url),
};
/** Upload a raw body with progress (fetch has no upload progress). */
export function uploadRaw<T>(method: string, url: string, file: Blob,
onProgress: (fraction: number) => void): Promise<T> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total);
xhr.onload = () => {
let data: any = null;
try {
data = JSON.parse(xhr.responseText);
} catch {
/* empty */
}
if (xhr.status >= 200 && xhr.status < 300) resolve(data);
else if (xhr.status === 413 && !data?.detail)
reject(new ApiError(413, "Файл слишком большой для прокси перед сервером (client_max_body_size)"));
else reject(new ApiError(xhr.status, data?.detail ?? (xhr.statusText || `Ошибка ${xhr.status}`)));
};
xhr.onerror = () => reject(new ApiError(0, "Сетевая ошибка"));
xhr.send(file);
});
}
export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Б`;
const units = ["КБ", "МБ", "ГБ", "ТБ"];
let v = bytes / 1024;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`;
}
+66 -3
View File
@@ -80,6 +80,38 @@ export function GamePage() {
if (name) void run(() => api.post(`/api/versions/${v.id}/fork`, { name }), "Форк создан");
};
const translate = async (v: Version) => {
const name = await ui.prompt({
title: "Новый перевод",
text: "Создаётся ваш черновик — копия версии, в которой можно переводить. Игроки увидят перевод после публикации.",
label: "Название черновика", initial: `Перевод (${me?.username})`, confirmLabel: "Начать перевод",
});
if (!name) return;
try {
const fork = await api.post<Version>(`/api/versions/${v.id}/fork`, { name, kind: "translation" });
navigate(`/versions/${fork.id}/translate`);
} catch (e) {
ui.toast((e as Error).message, "error");
}
};
const publish = async (v: Version) => {
const translationDraft = v.kind === "translation";
const ok = await ui.confirm({
title: `Опубликовать «${v.name}»?`,
text: translationDraft
? "Изменённые языки будут влиты в главную версию. Сохранения игроков сохранятся, прежнее состояние останется в истории."
: "Версию увидят все игроки.",
confirmLabel: "Опубликовать",
});
if (ok) void run(() => api.post(`/api/versions/${v.id}/publish`, {}), "Опубликовано");
};
const makeMain = async (v: Version) => {
if (await ui.confirm({ title: `Сделать «${v.name}» главной версией?`, text: "Кнопка «Играть» будет открывать её.", confirmLabel: "Сделать главной" }))
void run(() => api.post(`/api/versions/${v.id}/make-main`, {}), "Главная версия изменена");
};
const uploadCover = (file: File) =>
run(() => uploadRaw("PUT", `/api/games/${game.id}/cover`, file, () => {}), "Обложка обновлена");
@@ -111,7 +143,8 @@ export function GamePage() {
const ready = game.versions.filter((v) => v.status === "ready");
const lastId = getRecent().find((r) => r.gameId === game.id)?.versionId;
const playVersion = ready.find((v) => v.id === lastId) ?? ready.find((v) => v.parent_id === null) ?? ready[0];
const mainVersion = ready.find((v) => v.id === game.main_version_id);
const playVersion = ready.find((v) => v.id === lastId) ?? mainVersion ?? ready.find((v) => v.parent_id === null) ?? ready[0];
const byParent = new Map<number | null, Version[]>();
for (const v of game.versions) {
@@ -120,8 +153,10 @@ export function GamePage() {
}
const renderTree = (parent: number | null, depth: number): JSX.Element[] =>
(byParent.get(parent) ?? []).flatMap((v) => [
<VersionRow key={v.id} v={v} depth={depth} current={v.id === playVersion?.id}
<VersionRow key={v.id} v={v} depth={depth} current={v.id === playVersion?.id} isMain={v.id === game.main_version_id}
canManage={v.owner?.id === me?.id || can("versions.manage_any") || can("games.delete")}
canTranslate={can("translations.edit")} canPublish={can("versions.publish")}
onTranslate={() => void translate(v)} onPublish={() => void publish(v)} onMakeMain={() => void makeMain(v)}
canFork={can("versions.fork")} onPlay={() => play(v)} onFork={() => void fork(v)}
onRetry={() => void run(() => api.post(`/api/versions/${v.id}/retry`))}
onDelete={() => void removeVersion(v)} />,
@@ -188,9 +223,13 @@ export function GamePage() {
);
}
const KIND: Record<Version["kind"], string> = { original: "", translation: "Перевод", mod: "Мод" };
function VersionRow(props: {
v: Version; depth: number; current: boolean; canManage: boolean; canFork: boolean;
v: Version; depth: number; current: boolean; isMain: boolean; canManage: boolean; canFork: boolean;
canTranslate: boolean; canPublish: boolean;
onPlay: () => void; onFork: () => void; onRetry: () => void; onDelete: () => void;
onTranslate: () => void; onPublish: () => void; onMakeMain: () => void;
}) {
const { v } = props;
const [report, setReport] = useState<Version["report"] | undefined>(undefined);
@@ -206,16 +245,40 @@ function VersionRow(props: {
<div className="version-title">
{props.depth > 0 && <span className="fork-mark" title="Форк"><Icon name="fork" size={16} /></span>}
<b>{v.name}</b>
{props.isMain && <span className="badge badge-ok" title="Её открывает кнопка «Играть»">главная</span>}
{KIND[v.kind] && <span className="badge badge-plain">{KIND[v.kind]}</span>}
{v.visibility === "draft" && <span className="badge badge-warn">черновик</span>}
<StatusBadge status={v.status} />
</div>
<div className="muted small version-meta">
{v.owner?.username ?? "—"} · {new Date(v.created_at).toLocaleDateString()} · {formatSize(v.size_bytes)}
{v.progress && ` · ${v.progress}`}
</div>
{v.status === "ready" && v.languages?.length > 0 && (
<div className="tags" style={{ marginTop: "0.35rem" }} title="Языки версии">
<Icon name="globe" size={14} />
{v.languages.map((l) => (
<Link key={l.code} className="tag" to={`/versions/${v.id}/translate?lang=${l.code}`}>
{l.name} {Math.round(l.progress * 100)}%
</Link>
))}
</div>
)}
</div>
<div className="actions">
{v.status === "ready" && <button className="btn btn-primary btn-sm" onClick={props.onPlay}><Icon name="play" size={16} />Играть</button>}
{v.status === "ready" && props.canFork && <button className="btn btn-sm" onClick={props.onFork}><Icon name="fork" size={16} />Форк</button>}
{v.status === "ready" && props.canTranslate && v.kind === "translation" && v.visibility === "draft" && props.canManage ? (
<Link className="btn btn-sm" to={`/versions/${v.id}/translate`}><Icon name="globe" size={16} />Редактор перевода</Link>
) : v.status === "ready" && props.canTranslate && v.visibility === "published" && (
<button className="btn btn-sm" onClick={props.onTranslate}><Icon name="globe" size={16} />Перевести</button>
)}
{v.status === "ready" && props.canPublish && v.visibility === "draft" && (
<button className="btn btn-sm btn-primary" onClick={props.onPublish}><Icon name="send" size={16} />Опубликовать</button>
)}
{v.status === "ready" && props.canPublish && !props.isMain && v.visibility === "published" && v.kind !== "translation" && (
<button className="btn btn-sm btn-ghost" onClick={props.onMakeMain} title="Сделать главной"><Icon name="star" size={16} />Главная</button>
)}
{(v.status === "ready" || v.status === "failed") && (
<button className="btn btn-sm btn-ghost" onClick={() => void toggleReport()} aria-expanded={report !== undefined}>
<Icon name="file" size={16} />Отчёт
+590
View File
@@ -0,0 +1,590 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
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 { useUi } from "../ui/Ui";
import "./translate.css";
type LineState = "untranslated" | "outdated" | "translated" | "fuzzy" | "machine" | "approved";
interface LangStats {
code: string;
name: string;
font: string | null;
lines: number;
translated: number;
progress: number;
states: Record<LineState, number>;
strings: number;
stringsTranslated: number;
}
interface Overview {
sourceLang: string | null;
editable: boolean;
canApprove: boolean;
lines: number;
strings: number;
modules: { path: string; lines: number }[];
languages: LangStats[];
knownLanguages: Record<string, string>;
}
interface Item {
key: string;
source: string;
translation: string;
state: LineState;
// lines
nodeId?: string;
module?: string;
label?: string;
who?: string;
prev?: string | null;
next?: string | null;
scene?: string | null;
// strings
usage?: string;
}
interface GlossaryTerm {
src: string;
t: string;
note: string;
}
type Tab = "lines" | "strings" | "glossary";
const STATE_LABEL: Record<LineState, string> = {
untranslated: "Не переведено",
outdated: "Устарело",
fuzzy: "Неточно",
machine: "Машинный",
translated: "Переведено",
approved: "Одобрено",
};
const STATE_BADGE: Record<LineState, string> = {
untranslated: "badge-plain",
outdated: "badge-error",
fuzzy: "badge-warn",
machine: "badge-warn",
translated: "badge-ok",
approved: "badge-ok",
};
const FILTERS: [string, string][] = [
["todo", "К работе"], ["all", "Все"], ["untranslated", "Не переведено"], ["outdated", "Устарело"],
["fuzzy", "Неточно"], ["machine", "Машинный"], ["translated", "Переведено"], ["approved", "Одобрено"],
];
const PAGE = 40;
const USAGE: Record<string, string> = { name: "имя персонажа", menu: "пункт меню", text: "текст на экране" };
export function TranslationEditor() {
const { versionId } = useParams();
const [params, setParams] = useSearchParams();
const navigate = useNavigate();
const ui = useUi();
const [version, setVersion] = useState<Version | null>(null);
const [ov, setOv] = useState<Overview | null>(null);
const [error, setError] = useState("");
const lang = params.get("lang") ?? "";
const tab = (params.get("tab") as Tab) ?? "lines";
const setParam = (key: string, value: string) => {
const next = new URLSearchParams(params);
if (value) next.set(key, value);
else next.delete(key);
setParams(next, { replace: true });
};
const loadOverview = useCallback(async () => {
const [v, o] = await Promise.all([
api.get<Version>(`/api/versions/${versionId}`),
api.get<Overview>(`/api/versions/${versionId}/i18n`),
]);
setVersion(v);
setOv(o);
return o;
}, [versionId]);
useEffect(() => {
loadOverview()
.then((o) => {
if (!params.get("lang") && o.languages.length) setParam("lang", o.languages[0].code);
})
.catch((e) => setError(e.message));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadOverview]);
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 publish = async () => {
if (!version) return;
const ok = await ui.confirm({
title: "Опубликовать перевод?",
text: "Изменённые языки будут влиты в главную версию игры. Сохранения игроков не пострадают, "
+ "прежнее состояние останется в истории версии.",
confirmLabel: "Опубликовать",
});
if (!ok) return;
try {
await api.post(`/api/versions/${version.id}/publish`, {});
ui.toast("Перевод опубликован", "ok");
navigate(`/games/${version.game_id}`);
} catch (e) {
ui.toast((e as Error).message, "error");
}
};
const importFile = async (file: File) => {
const format = file.name.toLowerCase().endsWith(".csv") ? "csv" : "po";
const overwrite = await ui.confirm({
title: `Импорт ${file.name}`,
text: "Перезаписать уже переведённые строки? «Отмена» — заполнить только пустые.",
confirmLabel: "Перезаписать",
});
try {
const res = await uploadRaw<{ lines: number; strings: number }>(
"POST", `/api/versions/${versionId}/i18n/${lang}/import?format=${format}&overwrite=${overwrite}`, file, () => {});
ui.toast(`Импортировано: ${res.lines} реплик, ${res.strings} строк`, "ok");
await loadOverview();
setParam("r", String(Date.now()));
} catch (e) {
ui.toast((e as Error).message, "error");
}
};
const uploadFont = async (file: File) => {
try {
await uploadRaw("PUT", `/api/versions/${versionId}/i18n/${lang}/font?filename=${encodeURIComponent(file.name)}`,
file, () => {});
ui.toast("Шрифт для языка загружен", "ok");
await loadOverview();
} catch (e) {
ui.toast((e as Error).message, "error");
}
};
if (error) return <div className="page"><ErrorNote>{error}</ErrorNote></div>;
if (!ov || !version) return <div className="page"><Spinner /></div>;
const draft = version.visibility === "draft";
return (
<div className="page tr-page">
<Link to={`/games/${version.game_id}`} className="back-link"><Icon name="back" size={18} />К игре</Link>
<PageHeader
title={`Перевод · ${version.name}`}
subtitle={<>Исходный язык: <b>{ov.knownLanguages[ov.sourceLang ?? ""] ?? ov.sourceLang ?? "?"}</b>
{" · "}{ov.lines} реплик · {ov.strings} строк интерфейса
{draft && <> · <span className="badge badge-warn">черновик</span></>}</>}
actions={ov.canApprove && draft && version.kind === "translation" && (
<button className="btn btn-primary" onClick={() => void publish()}><Icon name="send" size={18} />Опубликовать</button>
)}
/>
{!ov.editable && (
<div className="note note-info" style={{ marginBottom: "var(--space-4)" }}>
<Icon name="info" size={18} />
<span>Только просмотр. Чтобы переводить, создайте на странице игры версию «Перевод» — это ваш черновик.</span>
</div>
)}
<div className="tr-langbar card">
<div className="tr-langs" role="tablist" aria-label="Языки">
{ov.languages.map((l) => (
<button key={l.code} role="tab" aria-selected={l.code === lang}
className={`tr-lang ${l.code === lang ? "on" : ""}`} onClick={() => setParam("lang", l.code)}>
<span>{l.name}</span>
<small>{Math.round(l.progress * 100)}%</small>
</button>
))}
{ov.editable && (
<button className="btn btn-sm btn-ghost" onClick={() => void addLanguage()}><Icon name="plus" size={16} />Язык</button>
)}
</div>
{stats && (
<>
<ProgressBar stats={stats} />
<div className="actions tr-tools">
<a className="btn btn-sm" href={`/api/versions/${versionId}/i18n/${lang}/export?format=po`}>
<Icon name="download" size={16} />PO
</a>
<a className="btn btn-sm" href={`/api/versions/${versionId}/i18n/${lang}/export?format=csv`}>
<Icon name="download" size={16} />CSV
</a>
{ov.editable && (
<label className="btn btn-sm">
<Icon name="upload" size={16} />Импорт PO/CSV
<input type="file" accept=".po,.csv" hidden
onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ""; if (f) void importFile(f); }} />
</label>
)}
{ov.editable && (
<label className="btn btn-sm" title="Если в шрифтах игры нет нужных символов">
<Icon name="type" size={16} />{stats.font ? "Сменить шрифт" : "Шрифт языка"}
<input type="file" accept=".ttf,.otf,.woff,.woff2" hidden
onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ""; if (f) void uploadFont(f); }} />
</label>
)}
<Link className="btn btn-sm btn-ghost" to={`/play/${versionId}?lang=${lang}`}><Icon name="play" size={16} />Играть на этом языке</Link>
</div>
</>
)}
</div>
{!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} />
) : stats ? (
<>
<div className="chips tr-tabs" role="tablist">
{([["lines", `Реплики · ${stats.lines}`], ["strings", `Имена и меню · ${stats.strings}`], ["glossary", "Глоссарий"]] as [Tab, string][]).map(([t, label]) => (
<button key={t} role="tab" aria-selected={tab === t} className={`chip ${tab === t ? "on" : ""}`}
onClick={() => setParam("tab", t === "lines" ? "" : t)}>{label}</button>
))}
</div>
<EntriesView key={`${lang}-${tab}-${params.get("r") ?? ""}`} versionId={versionId!} lang={lang} tab={tab}
ov={ov} onStats={(s) => setOv((o) => o && { ...o, languages: o.languages.map((l) => l.code === lang ? { ...l, ...s } : l) })} />
</>
) : null}
</div>
);
}
function ProgressBar({ stats }: { stats: LangStats }) {
const total = stats.lines || 1;
const seg = (state: LineState, cls: string) =>
stats.states[state] ? <div className={cls} style={{ width: `${(stats.states[state] / total) * 100}%` }}
title={`${STATE_LABEL[state]}: ${stats.states[state]}`} /> : null;
return (
<div className="tr-progress">
<div className="tr-bar">
{seg("approved", "seg-approved")}
{seg("translated", "seg-ok")}
{seg("fuzzy", "seg-warn")}
{seg("machine", "seg-warn")}
{seg("outdated", "seg-error")}
</div>
<span className="muted small">
{stats.translated} из {stats.lines} реплик ({Math.round(stats.progress * 100)}%)
{stats.states.outdated ? ` · устарело ${stats.states.outdated}` : ""}
{" · "}строк {stats.stringsTranslated}/{stats.strings}
</span>
</div>
);
}
function EntriesView({ versionId, lang, tab, ov, onStats }: {
versionId: string; lang: string; tab: Tab; ov: Overview; onStats: (s: Partial<LangStats>) => void;
}) {
const ui = useUi();
const [state, setState] = useState("todo");
const [module, setModule] = useState("");
const [query, setQuery] = useState("");
const [q, setQ] = useState("");
const [items, setItems] = useState<Item[] | null>(null);
const [total, setTotal] = useState(0);
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);
return () => clearTimeout(t);
}, [query]);
useEffect(() => {
api.get<{ glossary: GlossaryTerm[] }>(`/api/versions/${versionId}/i18n/${lang}/meta`)
.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}`);
}, [versionId, lang, tab, state, module, q]);
useEffect(() => {
if (tab === "glossary") return;
let cancelled = false;
setItems(null);
fetchPage(0).then((r) => {
if (cancelled) return;
setItems(r.items);
setTotal(r.total);
}).catch((e) => ui.toast(e.message, "error"));
return () => {
cancelled = true;
};
}, [fetchPage, tab, ui]);
const flush = useCallback(async () => {
window.clearTimeout(timer.current);
const changes = pending.current;
if (!Object.keys(changes).length) return;
pending.current = {};
setSaving("saving");
try {
const s = await api.put<Partial<LangStats>>(`/api/versions/${versionId}/i18n/${lang}`,
tab === "strings" ? { strings: changes } : { lines: changes });
onStats(s);
setSaving("saved");
} catch (e) {
pending.current = { ...changes, ...pending.current };
setSaving("error");
ui.toast((e as Error).message, "error");
}
}, [versionId, lang, tab, onStats, ui]);
// don't lose edits when leaving the page
useEffect(() => () => void flush(), [flush]);
const update = (key: string, t: string, nextState?: LineState) => {
setItems((list) => list?.map((it) => it.key === key ? {
...it, translation: t,
state: nextState ?? (t.trim() ? (it.state === "approved" || it.state === "untranslated" || it.state === "outdated" ? "translated" : it.state) : "untranslated"),
} : it) ?? null);
const st = nextState ?? "translated";
pending.current[key] = t.trim() ? { t, state: st === "untranslated" || st === "outdated" ? "translated" : st } : null;
window.clearTimeout(timer.current);
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") {
return <GlossaryEditor versionId={versionId} lang={lang} editable={ov.editable} terms={glossary} onChange={setGlossary} />;
}
return (
<div className="tr-entries">
<div className="toolbar tr-filters">
<div className="search">
<Icon name="search" size={20} />
<input type="search" placeholder="Поиск в оригинале, переводе или по персонажу…" value={query}
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="Файл скрипта">
<option value="">Все файлы</option>
{ov.modules.map((m) => <option key={m.path} value={m.path}>{m.path.replace(/^script\//, "").replace(/\.json$/, "")} · {m.lines}</option>)}
</select>
)}
<span className={`muted small tr-saving ${saving}`}>
{saving === "saving" ? "Сохранение…" : saving === "saved" ? "Сохранено" : saving === "error" ? "Ошибка сохранения" : ""}
</span>
</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>
))}
</div>
{items === null ? <Spinner /> : !items.length ? (
<EmptyState icon="check" title={state === "todo" ? "Здесь всё переведено" : "Ничего не найдено"} />
) : (
<>
<div className="muted small" style={{ marginBottom: "var(--space-2)" }}>Найдено: {total}</div>
<div className="tr-list">
{items.map((it, i) => (
<Row key={it.key} item={it} versionId={versionId} lang={lang} editable={ov.editable}
canApprove={ov.canApprove} terms={terms}
onChange={(t, s) => update(it.key, t, s)} onBlur={() => void flush()}
onNext={() => {
void flush();
const next = document.querySelectorAll<HTMLTextAreaElement>(".tr-list textarea")[i + 1];
next?.focus();
}} />
))}
</div>
{items.length < total && (
<div className="center" style={{ marginTop: "var(--space-4)" }}>
<button className="btn" onClick={() => void more()}>Показать ещё ({total - items.length})</button>
</div>
)}
</>
)}
</div>
);
}
function Row({ item, versionId, lang, editable, canApprove, terms, onChange, onBlur, onNext }: {
item: Item; versionId: string; lang: string; editable: boolean; canApprove: boolean; terms: GlossaryTerm[];
onChange: (t: string, state?: LineState) => void; onBlur: () => void; onNext: () => void;
}) {
const [context, setContext] = useState(false);
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight + 2}px`;
}, [item.translation]);
return (
<div className={`tr-row state-${item.state}`}>
<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>}
{item.label && <span className="muted">· {item.label}</span>}
<span className={`badge ${STATE_BADGE[item.state]}`}>{STATE_LABEL[item.state]}</span>
</div>
<p className="tr-text">{highlight(item.source, terms)}</p>
{item.module !== undefined && (
<div className="tr-row-actions">
<button className="btn btn-sm btn-ghost" onClick={() => setContext((c) => !c)} aria-expanded={context}>
<Icon name="info" size={14} />Контекст
</button>
{item.nodeId && (
<Link className="btn btn-sm btn-ghost" to={`/play/${versionId}?at=${item.nodeId}&lang=${lang}`} target="_blank">
<Icon name="play" size={14} />Играть отсюда
</Link>
)}
</div>
)}
{context && (
<div className="tr-context">
{item.scene && <img src={item.scene} alt="" loading="lazy" />}
<div>
{item.prev && <p className="muted small">↑ {item.prev}</p>}
<p className="small"><b>→ {item.source}</b></p>
{item.next && <p className="muted small">↓ {item.next}</p>}
</div>
</div>
)}
</div>
<div className="tr-dst">
<textarea ref={ref} rows={2} value={item.translation} readOnly={!editable} lang={lang}
placeholder={editable ? "Перевод…" : ""}
onChange={(e) => onChange(e.target.value)} onBlur={onBlur}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
onNext();
}
}} />
{editable && (
<div className="tr-row-actions">
{!item.translation && (
<button className="btn btn-sm btn-ghost" title="Скопировать оригинал" onClick={() => onChange(item.source)}>
<Icon name="copy" size={14} />Оригинал
</button>
)}
{item.translation && item.state === "outdated" && (
<button className="btn btn-sm" title="Оригинал изменился, но перевод верен"
onClick={() => onChange(item.translation, "translated")}><Icon name="check" size={14} />Актуально</button>
)}
{item.translation && (item.state === "fuzzy" || item.state === "machine") && (
<button className="btn btn-sm" onClick={() => onChange(item.translation, "translated")}>
<Icon name="check" size={14} />Проверено
</button>
)}
{canApprove && item.translation && item.state !== "approved" && item.state !== "outdated" && (
<button className="btn btn-sm" onClick={() => onChange(item.translation, "approved")}>
<Icon name="star" size={14} />Одобрить
</button>
)}
{item.translation && item.state !== "fuzzy" && (
<button className="btn btn-sm btn-ghost" title="Пометить как требующий проверки"
onClick={() => onChange(item.translation, "fuzzy")}>Неточно</button>
)}
</div>
)}
</div>
</div>
);
}
function highlight(text: string, terms: GlossaryTerm[]): ReactNode {
if (!terms.length) return text;
const escaped = terms.map((t) => t.src.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).sort((a, b) => b.length - a.length);
const re = new RegExp(`(${escaped.join("|")})`, "gi");
return text.split(re).map((part, i) => {
const term = i % 2 ? terms.find((t) => t.src.toLowerCase() === part.toLowerCase()) : undefined;
return term ? <mark key={i} title={`${term.t}${term.note ? ` — ${term.note}` : ""}`}>{part}</mark> : part;
});
}
function GlossaryEditor({ versionId, lang, editable, terms, onChange }: {
versionId: string; lang: string; editable: boolean; terms: GlossaryTerm[]; onChange: (t: GlossaryTerm[]) => void;
}) {
const ui = useUi();
const [rows, setRows] = useState<GlossaryTerm[]>(terms.length ? terms : [{ src: "", t: "", note: "" }]);
const save = async () => {
try {
await api.put(`/api/versions/${versionId}/i18n/${lang}`, { glossary: rows });
onChange(rows.filter((r) => r.src.trim()));
ui.toast("Глоссарий сохранён", "ok");
} catch (e) {
ui.toast((e as Error).message, "error");
}
};
const set = (i: number, key: keyof GlossaryTerm, value: string) =>
setRows((rs) => rs.map((r, n) => n === i ? { ...r, [key]: value } : r));
return (
<div className="card">
<p className="muted small" style={{ marginTop: 0 }}>
Имена и термины, которые нужно переводить единообразно. В репликах они подсвечиваются,
при наведении виден принятый перевод.
</p>
<div className="table-wrap">
<table className="table tr-glossary">
<thead><tr><th>Оригинал</th><th>Перевод</th><th>Заметка</th><th /></tr></thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>
<td><input value={r.src} readOnly={!editable} onChange={(e) => set(i, "src", e.target.value)} /></td>
<td><input value={r.t} readOnly={!editable} onChange={(e) => set(i, "t", e.target.value)} lang={lang} /></td>
<td><input value={r.note} readOnly={!editable} onChange={(e) => set(i, "note", e.target.value)} /></td>
<td>
{editable && (
<button className="btn btn-sm btn-ghost btn-icon btn-danger" aria-label="Удалить термин"
onClick={() => setRows((rs) => rs.filter((_, n) => n !== i))}><Icon name="x" size={14} /></button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{editable && (
<div className="actions" style={{ marginTop: "var(--space-3)" }}>
<button className="btn btn-sm" onClick={() => setRows((rs) => [...rs, { src: "", t: "", note: "" }])}>
<Icon name="plus" size={16} />Термин
</button>
<button className="btn btn-sm btn-primary" onClick={() => void save()}><Icon name="check" size={16} />Сохранить</button>
</div>
)}
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
/* Translation editor */
.tr-langbar { display: flex; flex-direction: column; gap: var(--space-3); margin-bottom: var(--space-4); }
.tr-langs { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; }
.tr-lang {
display: inline-flex;
align-items: baseline;
gap: 0.45rem;
padding: 0.4rem 0.8rem;
border-radius: var(--radius-s);
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text-2);
font: inherit;
cursor: pointer;
}
.tr-lang small { color: var(--muted); }
.tr-lang:hover { border-color: var(--border-strong); color: var(--text); }
.tr-lang.on { border-color: var(--accent); background: var(--accent-soft); color: var(--text); }
.tr-tools { flex-wrap: wrap; }
.tr-progress { display: flex; flex-direction: column; gap: 0.35rem; }
.tr-bar { display: flex; height: 8px; border-radius: 99px; overflow: hidden; background: var(--surface-3); }
.tr-bar > div { height: 100%; }
.seg-approved { background: var(--ok); }
.seg-ok { background: color-mix(in srgb, var(--ok) 60%, var(--surface-3)); }
.seg-warn { background: var(--accent); }
.seg-error { background: var(--danger); }
.tr-tabs { margin-bottom: var(--space-4); }
.tr-filters { margin-bottom: var(--space-3); flex-wrap: wrap; }
.tr-filters select { height: 2.9rem; max-width: 18rem; }
.tr-saving { min-width: 8rem; }
.tr-saving.error { color: var(--danger); }
.tr-saving.saved { color: var(--ok); }
.tr-list { display: flex; flex-direction: column; gap: var(--space-2); }
.tr-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--border-strong);
border-radius: var(--radius-s);
}
.tr-row.state-untranslated { border-left-color: var(--border-strong); }
.tr-row.state-outdated { border-left-color: var(--danger); }
.tr-row.state-fuzzy, .tr-row.state-machine { border-left-color: var(--accent); }
.tr-row.state-translated, .tr-row.state-approved { border-left-color: var(--ok); }
.tr-row:focus-within { border-color: var(--accent); }
.tr-meta { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; font-size: 0.85rem; }
.tr-meta .badge { margin-left: auto; }
.tr-text { margin: 0.35rem 0; white-space: pre-wrap; line-height: 1.5; }
.tr-text mark { background: var(--info-soft); color: inherit; border-bottom: 1px dotted var(--info); cursor: help; }
.tr-row-actions { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.tr-dst { display: flex; flex-direction: column; gap: 0.35rem; }
.tr-dst textarea { width: 100%; resize: vertical; min-height: 3.2rem; line-height: 1.5; font: inherit; }
.tr-dst textarea[readonly] { opacity: 0.8; }
.tr-context {
display: flex;
gap: var(--space-3);
margin-top: var(--space-2);
padding: var(--space-2);
border-radius: var(--radius-s);
background: var(--surface-2);
}
.tr-context img { width: 180px; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
.tr-context p { margin: 0.2rem 0; }
.tr-glossary input { width: 100%; }
@media (max-width: 860px) {
.tr-row { grid-template-columns: 1fr; gap: var(--space-2); }
.tr-context { flex-direction: column; }
.tr-context img { width: 100%; }
}
+57 -3
View File
@@ -407,6 +407,7 @@ export class Engine {
try {
this.tr = await this.loadLocale(lang);
rememberLanguage(lang);
this.applyLocaleFont();
this.notify(languageName(this.game, lang));
} catch (err) {
this.notify(`Не удалось загрузить язык: ${(err as Error).message}`);
@@ -995,7 +996,53 @@ export class Engine {
this.showMainMenu();
}
private newGame() {
/** Preview from a given line (translation editor): game variables keep their defaults. */
startAt(nodeId: string) {
const at = this.findNode(nodeId);
if (!at) {
this.start();
this.notify("Строка не найдена в этой версии");
return;
}
this.newGame(at);
// rebuild the picture: replay the visual statements of the label that come before the line
const body = this.labels.get(at.label)!.label.body;
for (const n of body.slice(0, at.path[0])) {
if (n.op === "scene") {
this.renderer.scene();
if (n.img) this.showNode(n);
} else if (n.op === "show") this.showNode(n);
else if (n.op === "hide") this.renderer.hide(n.tag);
}
this.renderer.commit();
this.notify("Предпросмотр с выбранной строки — переменные игры по умолчанию");
}
private findNode(id: string): Frame | null {
const visit = (nodes: Node[], label: string, prefix: number[]): Frame | null => {
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
const path = [...prefix, i];
if (n.id === id || (n.op === "menu" && n.say?.id === id)) return { label, path };
const children: [Node[], number][] =
n.op === "if" ? n.branches.map((b: any, k: number) => [b.body, k])
: n.op === "menu" ? n.items.map((it: any, k: number) => [it.body, k])
: n.op === "while" ? [[n.body, 0]] : [];
for (const [body, k] of children) {
const hit = visit(body, label, [...path, k]);
if (hit) return hit;
}
}
return null;
};
for (const { label } of this.labels.values()) {
const hit = visit(label.body, label.name, []);
if (hit) return hit;
}
return null;
}
private newGame(at?: Frame) {
this.restart(() => {
this.audio.stopAll();
this.renderer.scene();
@@ -1006,8 +1053,8 @@ export class Engine {
this.history = [];
this.lastSay = null;
this.textbox.classList.add("pg-hidden");
this.cur = { label: this.game.entry, path: [0] };
this.markSeen(this.game.entry);
this.cur = at ?? { label: this.game.entry, path: [0] };
this.markSeen(this.cur.label);
this.settle();
});
}
@@ -1095,6 +1142,7 @@ export class Engine {
if (ui.text_color) stage.style.setProperty("--pg-text", ui.text_color);
this.loadFont("pg-game-font", ui.text_font);
this.loadFont("pg-game-name-font", ui.name_text_font ?? ui.text_font);
this.applyLocaleFont();
this.textbox = el("div", "pg-textbox pg-hidden");
const boxH = px(ui.textbox_height, h, Math.round(h * 0.257));
@@ -1171,6 +1219,12 @@ export class Engine {
}, { passive: true });
}
/** A translation may bring its own font when the game's fonts lack its glyphs. */
private applyLocaleFont() {
this.renderer.stage.classList.remove("pg-locale-font");
if (this.tr.font) this.loadFont("pg-locale-font", this.tr.font);
}
private loadFont(family: string, file?: string) {
const url = file ? this.fileUrl(file) : null;
if (!url) return;
+6 -2
View File
@@ -7,6 +7,7 @@ export interface LocaleInfo {
renpy: string;
modules: Record<string, string>;
strings: string;
font?: string | null;
lines: number;
progress: number;
}
@@ -24,6 +25,7 @@ export class Translator {
public readonly renpyName: string | null,
private lines: Map<string, string> = new Map(),
private strings: Map<string, string> = new Map(),
public readonly font: string | null = null, // logical path of the locale's font
) {}
static source(game: GameData) {
@@ -77,11 +79,13 @@ export async function loadTranslator(game: GameData, manifest: Manifest, lang: s
return r.json() as Promise<Record<string, Entry>>;
});
};
const [strings, ...modules] = await Promise.all([blob(info.strings), ...Object.values(info.modules).map(blob)]);
const [strings, ...modules] = await Promise.all([
info.strings ? blob(info.strings) : Promise.resolve({} as Record<string, Entry>),
...Object.values(info.modules).map(blob)]);
const lines = new Map<string, string>();
for (const mod of modules) for (const [tid, e] of Object.entries(mod)) lines.set(tid, e.t);
return new Translator(lang, info.renpy, lines,
new Map(Object.entries(strings).map(([k, e]) => [k, e.t])));
new Map(Object.entries(strings).map(([k, e]) => [k, e.t])), info.font ?? null);
}
export function languageName(game: GameData, lang: string): string {
+7 -2
View File
@@ -45,7 +45,10 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () =>
const manifest = await getJSON<Manifest>(`/api/versions/${versionId}/manifest`);
const game = await getJSON<GameData>(blob(manifest, "game.json"));
onProgress(`Загрузка скриптов (${game.scripts.length})`);
const lang = preferredLanguage(game);
// ?lang= and ?at= come from the translation editor ("play from here")
const url = new URLSearchParams(location.search);
const asked = url.get("lang");
const lang = asked && (asked === game.sourceLang || game.locales?.[asked]) ? asked : preferredLanguage(game);
const [modules, tr] = await Promise.all([
Promise.all(game.scripts.map((s) => getJSON<ScriptModule>(blob(manifest, s)))),
loadTranslator(game, manifest, lang),
@@ -53,6 +56,8 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () =>
const engine = new Engine(root, game, manifest, modules, saveApi(versionId),
`pgvn:persistent:${versionId}`, onExit, tr,
(code) => loadTranslator(game, manifest, code));
engine.start();
const at = url.get("at");
if (at) engine.startAt(at);
else engine.start();
return engine;
}
+1
View File
@@ -23,6 +23,7 @@
}
.pg-stage.pg-game-font { font-family: "pg-game-font", "Segoe UI", system-ui, sans-serif; }
.pg-stage.pg-game-name-font .pg-namebox { font-family: "pg-game-name-font", "Segoe UI", system-ui, sans-serif; }
.pg-stage.pg-locale-font, .pg-stage.pg-locale-font .pg-namebox { font-family: "pg-locale-font", "Segoe UI", system-ui, sans-serif; }
.pg-layer { position: absolute; inset: 0; }
.pg-transition { pointer-events: none; }
+6
View File
@@ -31,6 +31,12 @@ const PATHS = {
clock: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Zm0-15v5l3 2",
menu: "M4 6h16M4 12h16M4 18h16",
power: "M12 2v10m6.4-6.4a9 9 0 1 1-12.8 0",
globe: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20ZM2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10Z",
download: "M12 4v12m0 0-5-5m5 5 5-5M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3",
book: "M4 19.5A2.5 2.5 0 0 1 6.5 17H20V3H6.5A2.5 2.5 0 0 0 4 5.5v14ZM4 19.5A2.5 2.5 0 0 0 6.5 22H20v-5",
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",
} as const;
export type IconName = keyof typeof PATHS;