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:
@@ -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"
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту
|
||||
|
||||
@@ -154,7 +154,7 @@ function TelegramNicknamePicker({
|
||||
</div>
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
<button className="btn btn-ghost small" onClick={onCancel}>
|
||||
Назад
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
Reference in New Issue
Block a user