chore: add CI, lint config, login rate limit and split large modules

- 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>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 11:23:22 +03:00
1 parent 57a4edcbfd
commit bbbcc6a5d8
42 files changed
+678 -391

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
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>
);
}