UI: согласованные тексты и терминология

- «Winrate» -> «Винрейт» в карточке статистики; в справке добавлена
  расшифровка «Винрейт (WR)» — WR остаётся в компактных таблицах;
- кнопки сохранения: «ОК» -> «Сохранить» (аккаунт, фракции, аккаунты
  в админке); отмена в модалках/формах — везде «Отмена», не «Назад»;
- пустые состояния приведены к шаблону «X пока нет.»;
- роли участников группы переведены: owner/member -> владелец/участник,
  бейдж admin -> «админ»; «Имя обновлено» -> «Ник обновлён»;
- «N игр.» в значении числа игроков -> «N игроков» со склонением
  (новый хелпер plural в domain/format.ts);
- «минимум 2 участника» -> «минимум 2 игрока» (в партии — игроки,
  в группе — участники).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 17:44:08 +03:00
co-authored by Claude Fable 5
parent 002c657855
commit e38555b74d
15 changed files with 30 additions and 19 deletions
@@ -1,7 +1,7 @@
import { ChevronRight } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { formatTime } from "../domain/format";
import { formatTime, plural } from "../domain/format";
import type { HomeInProgressMatch } from "../domain/types";
import { PlayerLink } from "./PlayerLink";
@@ -37,7 +37,8 @@ export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) {
<span className="badge" style={{ color: "var(--accent-2)" }}>идёт</span>
</div>
<div className="muted small">
{formatTime(m.started_at)} · {m.player_count} игр.
{formatTime(m.started_at)} · {m.player_count}{" "}
{plural(m.player_count, "игрок", "игрока", "игроков")}
</div>
<div
className="small"
+1 -1
View File
@@ -40,7 +40,7 @@ export function Leaderboard({
const empty =
entries.length === 0 && !provisional?.length && !inactive?.length;
if (empty) {
return <div className="muted small">Пока нет сыгранных партий.</div>;
return <div className="muted small">Сыгранных партий пока нет.</div>;
}
return (
<div>
@@ -22,7 +22,7 @@ export function NotificationPanel({
<div className="notif-panel">
<div className="notif-panel-head">Уведомления</div>
{items.length === 0 ? (
<div className="notif-empty muted small">Уведомлений нет</div>
<div className="notif-empty muted small">Уведомлений пока нет.</div>
) : (
<div className="notif-list">
{items.map((n) => (
+1 -1
View File
@@ -24,7 +24,7 @@ export function ProfileStatsCard({
<b>{o.wins}</b>
</div>
<div className="row-between">
<div>Winrate</div>
<div>Винрейт</div>
<b>{pct(o.win_rate)}</b>
</div>
<div className="row-between">
+9
View File
@@ -9,6 +9,15 @@ function shifted(iso: string): Date {
return new Date(new Date(iso).getTime() + APP_TZ_OFFSET_MIN * 60_000);
}
// Склонение существительного при числе: plural(3, "игрок", "игрока", "игроков").
export function plural(n: number, one: string, few: string, many: string): string {
const m10 = n % 10;
const m100 = n % 100;
if (m10 === 1 && m100 !== 11) return one;
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few;
return many;
}
export function formatDuration(minutes: number | null | undefined): string {
if (minutes == null) return "—";
const h = Math.floor(minutes / 60);
+1 -1
View File
@@ -145,7 +145,7 @@ export function AccountPage() {
<div className="row">
<input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={me.nickname} />
<button className="btn btn-primary" onClick={saveNick} disabled={updateNick.isPending}>
ОК
Сохранить
</button>
</div>
</div>
+1 -1
View File
@@ -134,7 +134,7 @@ export function CreateMatchPage() {
setError(null);
const filled = rows.filter((r) => r.user_id !== "" && r.faction_id !== "");
if (filled.length < 2) {
setError("Нужно минимум 2 участника с выбранным игроком и фракцией.");
setError("Нужно минимум 2 игрока с выбранной фракцией.");
return;
}
if (filled.length > MAX_MATCH_PLAYERS) {
+2 -1
View File
@@ -120,7 +120,8 @@ export function GroupSettingsPage() {
<div key={m.user_id} className="row-between">
{/* Кликабельна вся левая часть строки, не только текст ника. */}
<PlayerLink userId={m.user_id} style={{ flex: 1 }}>
{m.nickname} <span className="muted small">· {m.role}</span>
{m.nickname}{" "}
<span className="muted small">· {m.role === "owner" ? "владелец" : "участник"}</span>
</PlayerLink>
{isOwner && m.role !== "owner" && (
<button
+1 -1
View File
@@ -63,7 +63,7 @@ export function HelpPage() {
<div className="card">
<h3>Другие показатели</h3>
<ul className="small" style={{ margin: 0, paddingLeft: 18 }}>
<li><b>Винрейт</b> — доля партий, в которых игрок занял 1-е место.</li>
<li><b>Винрейт (WR)</b> — доля партий, в которых игрок занял 1-е место.</li>
<li><b>Среднее место</b> — среднее по завершённым партиям.</li>
<li>
При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту
+1 -1
View File
@@ -154,7 +154,7 @@ function TelegramNicknamePicker({
</div>
{error && <p className="error-text">{error}</p>}
<button className="btn btn-ghost small" onClick={onCancel}>
Назад
Отмена
</button>
</div>
);
-1
View File
@@ -293,7 +293,6 @@ export function MatchDetailPage() {
: "Сыгранная партия и её результаты будут удалены. Действие необратимо."
}
confirmLabel={inProgress ? "Отменить партию" : "Удалить"}
cancelLabel="Назад"
onCancel={() => setConfirmRemove(false)}
onConfirm={() => {
setConfirmRemove(false);
@@ -28,7 +28,7 @@ export function AdminAccountsPage() {
if (editId == null || !name) return;
try {
await update.mutateAsync({ userId: editId, nickname: name });
toast.show("Имя обновлено");
toast.show("Ник обновлён");
setEditId(null);
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось переименовать");
@@ -63,7 +63,7 @@ export function AdminAccountsPage() {
disabled={update.isPending || !editName.trim() || editName.trim() === u.nickname}
onClick={saveName}
>
ОК
Сохранить
</button>
<button className="btn btn-ghost small" onClick={() => setEditId(null)}>
Отмена
@@ -72,7 +72,7 @@ export function AdminAccountsPage() {
) : (
<div className="row-between">
<div>
<b>{u.nickname}</b> {u.role === "admin" && <span className="badge">admin</span>}
<b>{u.nickname}</b> {u.role === "admin" && <span className="badge">админ</span>}
<div className="muted small">
#{u.id} · {u.auth_provider} · {u.is_active ? "активен" : "отключён"}
</div>
@@ -48,7 +48,7 @@ export function AdminFactionsPage() {
disabled={!names[f.id] || names[f.id] === f.name_ru}
onClick={() => save(f.id)}
>
ОК
Сохранить
</button>
</div>
</div>
+1 -1
View File
@@ -59,7 +59,7 @@ export function AdminLogsPage() {
)}
</div>
))}
{(data?.items ?? []).length === 0 && <div className="muted small">Событий нет.</div>}
{(data?.items ?? []).length === 0 && <div className="muted small">Событий пока нет.</div>}
</div>
)}
</div>
@@ -3,7 +3,7 @@ import { useMemo, useState } from "react";
import { ApiError } from "../../api/client";
import { ConfirmDialog } from "../../components/ConfirmDialog";
import { formatDate } from "../../domain/format";
import { formatDate, plural } from "../../domain/format";
import { Spinner } from "../../components/Spinner";
import { Switch } from "../../components/Switch";
import { useToast } from "../../context/ToastContext";
@@ -58,7 +58,8 @@ export function AdminRecordsPage() {
<b>#{m.id}</b> · {formatDate(m.played_at)}
{!grouped && <span className="muted small"> · {m.group_name}</span>}
<div className="muted small">
{m.player_count} игр. · автор {m.created_by}
{m.player_count} {plural(m.player_count, "игрок", "игрока", "игроков")} · автор{" "}
{m.created_by}
</div>
</div>
<div className="row" style={{ gap: 4 }}>
@@ -120,7 +121,7 @@ export function AdminRecordsPage() {
{isLoading ? (
<Spinner />
) : (matches ?? []).length === 0 ? (
<div className="muted small">Записей нет.</div>
<div className="muted small">Записей пока нет.</div>
) : grouped ? (
byGroup.map(([gid, { name, items }]) => (
<div key={gid} style={{ marginBottom: 16 }}>