- Rate-limit failed logins (8 per 15 min per IP or username, 429 + Retry-After) - Add ruff/mypy config and GitHub Actions workflow (ruff, pytest, tsc, tests, build) - Apply ruff autofixes (import order, pyupgrade) - Extract rpy_helpers.py, engine-util.ts, TranslationParts.tsx and translation-types.ts from oversized modules - Add dependency-free React UI tests - Refresh README and ARCHITECTURE (Naninovel/Tomie, status and plans) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
94 lines
4.3 KiB
TypeScript
94 lines
4.3 KiB
TypeScript
import { useState, type ReactNode } from "react";
|
||
import { api } from "../api";
|
||
import { Icon } from "../ui/Icon";
|
||
import { useUi } from "../ui/Ui";
|
||
import { STATE_LABEL, type GlossaryTerm, type LangStats, type LineState } from "./translation-types";
|
||
|
||
export 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>
|
||
);
|
||
}
|
||
|
||
|
||
export 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;
|
||
});
|
||
}
|
||
|
||
export 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>
|
||
);
|
||
}
|