diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py
index a7f20b5..424a769 100644
--- a/backend/app/routers/users.py
+++ b/backend/app/routers/users.py
@@ -173,3 +173,22 @@ def nickname_available(
) -> s.NicknameAvailable:
available = user_service.nickname_available(session, value.strip(), exclude_user_id=user.id)
return s.NicknameAvailable(available=available)
+
+
+@router.get("/search", response_model=list[s.UserSuggestion])
+def search_users(
+ q: str = Query("", max_length=64),
+ limit: int = Query(3, ge=1, le=10),
+ session: Session = Depends(get_session),
+ _user: User = Depends(get_current_user),
+) -> list[s.UserSuggestion]:
+ """Автокомплит по нику. Пустой q — пустой список (не ошибка): удобнее при наборе."""
+ users = user_service.search_users(session, q, limit=limit)
+ return [
+ s.UserSuggestion(
+ user_id=u.id, # type: ignore[arg-type]
+ nickname=u.nickname,
+ avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type]
+ )
+ for u in users
+ ]
diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py
index d602f12..a7c2152 100644
--- a/backend/app/schemas/api.py
+++ b/backend/app/schemas/api.py
@@ -112,6 +112,14 @@ class NicknameAvailable(BaseModel):
available: bool
+class UserSuggestion(BaseModel):
+ """Подсказка автокомплита по нику (приглашение в группу и т.п.)."""
+
+ user_id: int
+ nickname: str
+ avatar_url: str | None = None
+
+
# ─── Группы и членство ───────────────────────────────────────────────────────
class GroupCreate(BaseModel):
diff --git a/backend/app/services/scoring.py b/backend/app/services/scoring.py
index 389b86c..ac705d2 100644
--- a/backend/app/services/scoring.py
+++ b/backend/app/services/scoring.py
@@ -9,7 +9,7 @@
from __future__ import annotations
# Порог числа игр для попадания в ранжированный топ (ниже — «Новички»/provisional).
-MIN_GAMES = 3
+MIN_GAMES = 5
# Порог числа игр на фракцию для расчёта лучшей/худшей фракции.
FACTION_MIN_GAMES = 2
diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py
index 77907e2..13d7916 100644
--- a/backend/app/services/user_service.py
+++ b/backend/app/services/user_service.py
@@ -34,6 +34,28 @@ def nickname_available(session: Session, nickname: str, exclude_user_id: int | N
return existing is None or existing.id == exclude_user_id
+def search_users(session: Session, query: str, limit: int = 3) -> list[User]:
+ """Подсказки по нику: активные игроки (role='player'), без учёта регистра.
+
+ SQLite LIKE/lower() сворачивают регистр только для ASCII, а ники бывают
+ кириллицей — поэтому выбираем активных игроков и фильтруем/ранжируем в
+ Python (casefold). Пользователей десятки, полный проход дёшев; при росте
+ до тысяч — заменить на индексированную колонку nickname_lower.
+ Сначала префиксные совпадения, затем подстрочные, внутри — по алфавиту.
+ """
+ q = (query or "").strip().casefold()
+ if not q:
+ return []
+ users = session.exec(
+ select(User).where(User.role == "player", User.is_active == True) # noqa: E712
+ ).all()
+ matched = [u for u in users if q in u.nickname.casefold()]
+ matched.sort(
+ key=lambda u: (0 if u.nickname.casefold().startswith(q) else 1, u.nickname.casefold())
+ )
+ return matched[:limit]
+
+
def nickname_format_ok(nickname: str) -> bool:
"""Ник подходит по формату (2–64, буквы/цифры/пробел/.-_)."""
return bool(_NICK_RE.match((nickname or "").strip()))
diff --git a/backend/tests/test_user_search.py b/backend/tests/test_user_search.py
new file mode 100644
index 0000000..25c9186
--- /dev/null
+++ b/backend/tests/test_user_search.py
@@ -0,0 +1,65 @@
+"""Поиск игроков для автокомплита по нику: GET /api/users/search."""
+from __future__ import annotations
+
+from fastapi.testclient import TestClient
+from sqlmodel import Session
+
+from app.models import User
+from tests.conftest import login
+
+
+def _add_user(engine, nickname: str, *, active: bool = True, role: str = "player") -> None:
+ """Готовим данные напрямую в БД: нужны выключенные аккаунты и админы."""
+ with Session(engine) as s:
+ s.add(
+ User(
+ nickname=nickname,
+ role=role,
+ auth_provider="stub" if role == "player" else "local",
+ is_active=active,
+ # CHECK ck_users_admin_has_password: админ обязан иметь пароль.
+ password_hash="x" if role == "admin" else None,
+ )
+ )
+ s.commit()
+
+
+def test_search_prefix_first_cyrillic_case_insensitive(client: TestClient, engine):
+ login(client, "Искатель")
+ for nick in ("Барсик", "барс", "Морбарс", "Zed"):
+ _add_user(engine, nick)
+
+ r = client.get("/api/users/search", params={"q": "БАР"})
+ assert r.status_code == 200, r.text
+ got = r.json()
+ # Префиксные раньше подстрочных, внутри — по алфавиту; регистр кириллицы не важен.
+ assert [u["nickname"] for u in got] == ["барс", "Барсик", "Морбарс"]
+ assert {"user_id", "nickname", "avatar_url"} <= set(got[0].keys())
+
+
+def test_search_empty_query_returns_empty_list(client: TestClient):
+ login(client, "Искатель")
+ r = client.get("/api/users/search", params={"q": " "})
+ assert r.status_code == 200, r.text
+ assert r.json() == []
+
+
+def test_search_skips_inactive_and_admins(client: TestClient, engine):
+ login(client, "Искатель")
+ _add_user(engine, "Спящий барсук", active=False)
+ _add_user(engine, "адм барс", role="admin")
+
+ r = client.get("/api/users/search", params={"q": "барс"})
+ assert r.status_code == 200, r.text
+ assert r.json() == []
+
+
+def test_search_respects_limit(client: TestClient, engine):
+ login(client, "Искатель")
+ for i in range(5):
+ _add_user(engine, f"Игрок {i}")
+
+ assert len(client.get("/api/users/search", params={"q": "игрок"}).json()) == 3
+ assert (
+ len(client.get("/api/users/search", params={"q": "игрок", "limit": 5}).json()) == 5
+ )
diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts
index cbd05fc..951a533 100644
--- a/frontend/src/api/queryKeys.ts
+++ b/frontend/src/api/queryKeys.ts
@@ -7,6 +7,7 @@ export const qk = {
factions: ["factions"] as const,
myStats: ["myStats"] as const,
publicProfile: (id: number) => ["publicProfile", id] as const,
+ userSearch: (q: string, limit: number) => ["userSearch", q, limit] as const,
groups: ["groups"] as const,
group: (id: number) => ["group", id] as const,
groupFactions: (id: number) => ["group", id, "factions"] as const,
diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts
index 8ec6f32..553e44e 100644
--- a/frontend/src/api/schema.d.ts
+++ b/frontend/src/api/schema.d.ts
@@ -225,6 +225,26 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/users/search": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Search Users
+ * @description Автокомплит по нику. Пустой q — пустой список (не ошибка): удобнее при наборе.
+ */
+ get: operations["search_users_api_users_search_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/api/groups": {
parameters: {
query?: never;
@@ -1975,6 +1995,18 @@ export interface components {
/** Avatar Url */
avatar_url?: string | null;
};
+ /**
+ * UserSuggestion
+ * @description Подсказка автокомплита по нику (приглашение в группу и т.п.).
+ */
+ UserSuggestion: {
+ /** User Id */
+ user_id: number;
+ /** Nickname */
+ nickname: string;
+ /** Avatar Url */
+ avatar_url?: string | null;
+ };
/** ValidationError */
ValidationError: {
/** Location */
@@ -2388,6 +2420,38 @@ export interface operations {
};
};
};
+ search_users_api_users_search_get: {
+ parameters: {
+ query?: {
+ q?: string;
+ limit?: number;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserSuggestion"][];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
my_groups_api_groups_get: {
parameters: {
query?: never;
diff --git a/frontend/src/components/CreateGroupForm.tsx b/frontend/src/components/CreateGroupForm.tsx
index 9e40252..5037bc9 100644
--- a/frontend/src/components/CreateGroupForm.tsx
+++ b/frontend/src/components/CreateGroupForm.tsx
@@ -4,6 +4,7 @@ import { ApiError } from "../api/client";
import { useToast } from "../context/ToastContext";
import { useCreateGroup } from "../hooks/groups";
import { useExpansions } from "../hooks/reference";
+import { Switch } from "./Switch";
export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
const { data: expansions } = useExpansions();
@@ -47,9 +48,7 @@ export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
{e.name_ru}
{e.is_base && (всегда включено) }
- toggle(e.id)}
@@ -59,7 +58,7 @@ export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
{error &&
{error}
}
-
+
Создать
diff --git a/frontend/src/components/GroupSwitcher.tsx b/frontend/src/components/GroupSwitcher.tsx
index 067c358..d67d415 100644
--- a/frontend/src/components/GroupSwitcher.tsx
+++ b/frontend/src/components/GroupSwitcher.tsx
@@ -21,17 +21,13 @@ export function GroupSwitcher() {
className={"btn" + (active ? " btn-primary" : "")}
onClick={() => !active && setActive.mutate(g.id)}
>
- {g.name} {active && "✓"}
+ {g.name} {active && "✓"}
);
})}
{/* Плашка создания новой группы — в одном ряду логики с выбором группы. */}
- setCreating((v) => !v)}
- >
- +
+ setCreating((v) => !v)}>
+ Создать группу
diff --git a/frontend/src/components/InProgressMatches.tsx b/frontend/src/components/InProgressMatches.tsx
index bb9917b..d4528c7 100644
--- a/frontend/src/components/InProgressMatches.tsx
+++ b/frontend/src/components/InProgressMatches.tsx
@@ -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";
export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) {
@@ -15,30 +15,25 @@ export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) {
{items.map((m) => (
navigate(`/match/${m.id}`)}
- style={{
- width: "100%",
- textAlign: "left",
- background: "var(--surface-2)",
- border: "1px solid var(--border)",
- borderRadius: "var(--radius-sm)",
- padding: 12,
- color: "var(--text)",
- }}
+ style={{ margin: 0, width: "100%", textAlign: "left" }}
>
{m.group_name}
- идёт
+ идёт
- {formatTime(m.started_at)} · {m.player_count} игр.
+ {formatTime(m.started_at)} · {m.player_count}{" "}
+ {plural(m.player_count, "игрок", "игрока", "игроков")}
+ {/* Ники — просто текст: карточка целиком ведёт в партию,
+ как и в списке партий группы. */}
{m.participants.map((p) => p.nickname).join(", ")}
diff --git a/frontend/src/components/Leaderboard.tsx b/frontend/src/components/Leaderboard.tsx
index e5e13f2..7e513b1 100644
--- a/frontend/src/components/Leaderboard.tsx
+++ b/frontend/src/components/Leaderboard.tsx
@@ -1,22 +1,22 @@
-import { Link } from "react-router-dom";
-
import type { LeaderboardEntry } from "../domain/types";
import { Avatar } from "./Avatar";
+import { PlayerLink } from "./PlayerLink";
function pct(v: number | null | undefined): string {
return v == null ? "—" : `${Math.round(v * 100)}%`;
}
-function Row({ entry }: { entry: LeaderboardEntry }) {
- return (
- // Вся строка — ссылка на профиль. stopPropagation: на главной карточка-обёртка
- // ведёт в общую статистику, а клик по строке игрока — в его профиль.
- e.stopPropagation()}
- >
+function Row({
+ entry,
+ provisional,
+ profileLinks,
+}: {
+ entry: LeaderboardEntry;
+ provisional?: boolean;
+ profileLinks: boolean;
+}) {
+ const content = (
+ <>
{entry.rank ?? "—"}
@@ -29,8 +29,16 @@ function Row({ entry }: { entry: LeaderboardEntry }) {
WR: {pct(entry.win_rate)}
- {entry.score ?? "—"}
-
+ {entry.score ?? "—"}
+ >
+ );
+ // Строка-ссылка на профиль — только там, где сама карточка никуда не ведёт
+ // (на главной карточка топа целиком открывает общую статистику).
+ if (!profileLinks) return {content}
;
+ return (
+
+ {content}
+
);
}
@@ -38,20 +46,22 @@ export function Leaderboard({
entries,
provisional,
inactive,
+ profileLinks = true,
}: {
entries: LeaderboardEntry[];
provisional?: LeaderboardEntry[];
inactive?: LeaderboardEntry[];
+ profileLinks?: boolean;
}) {
const empty =
entries.length === 0 && !provisional?.length && !inactive?.length;
if (empty) {
- return Пока нет сыгранных партий.
;
+ return Сыгранных партий пока нет.
;
}
return (
{entries.map((e) => (
-
+
))}
{provisional && provisional.length > 0 && (
<>
@@ -59,7 +69,7 @@ export function Leaderboard({
Новички (мало игр)
{provisional.map((e) => (
-
+
))}
>
)}
@@ -69,7 +79,7 @@ export function Leaderboard({
Ещё не играли
{inactive.map((e) => (
-
+
))}
>
)}
diff --git a/frontend/src/components/MatchList.tsx b/frontend/src/components/MatchList.tsx
index 7aac38b..36d0555 100644
--- a/frontend/src/components/MatchList.tsx
+++ b/frontend/src/components/MatchList.tsx
@@ -1,6 +1,6 @@
import { useNavigate } from "react-router-dom";
-import { formatDuration } from "../domain/format";
+import { formatDate, formatDuration } from "../domain/format";
import { winReasonLabel } from "../domain/winReasons";
import type { MatchListItem } from "../domain/types";
@@ -21,9 +21,9 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
onClick={() => navigate(`/match/${m.id}`)}
>
- {m.played_at}
+ {formatDate(m.played_at)}
{inProgress ? (
- идёт
+ идёт
) : (
{formatDuration(m.duration_minutes)} · {winReasonLabel(m.win_reason)}
@@ -31,6 +31,8 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
)}
+ {/* Ники здесь — просто текст: карточка целиком ведёт в партию,
+ вложенные ссылки на профили мешали бы выбору партии. */}
{sorted.map((p) => (
{p.nickname}
· {p.faction_name}
+ {p.was_random && 🎲 }
))}
diff --git a/frontend/src/components/NotificationPanel.tsx b/frontend/src/components/NotificationPanel.tsx
index 2cf981d..53f1c6f 100644
--- a/frontend/src/components/NotificationPanel.tsx
+++ b/frontend/src/components/NotificationPanel.tsx
@@ -22,7 +22,7 @@ export function NotificationPanel({
Уведомления
{items.length === 0 ? (
-
Уведомлений нет
+
Уведомлений пока нет.
) : (
{items.map((n) => (
diff --git a/frontend/src/components/PlayerLink.tsx b/frontend/src/components/PlayerLink.tsx
new file mode 100644
index 0000000..51ad8cd
--- /dev/null
+++ b/frontend/src/components/PlayerLink.tsx
@@ -0,0 +1,31 @@
+import type { CSSProperties, ReactNode } from "react";
+import { Link } from "react-router-dom";
+
+/**
+ * Ссылка на профиль игрока (/u/:id). Оборачивает всё поле с ником (строку с аватаром,
+ * местом, фракцией…), а не голый текст. stopPropagation — строки часто лежат внутри
+ * кликабельных карточек (список партий, топ на главной), и клик по игроку должен
+ * вести в профиль, а не срабатывать навигацией карточки.
+ */
+export function PlayerLink({
+ userId,
+ className,
+ style,
+ children,
+}: {
+ userId: number;
+ className?: string;
+ style?: CSSProperties;
+ children: ReactNode;
+}) {
+ return (
+
e.stopPropagation()}
+ >
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/PlayerSuggestions.tsx b/frontend/src/components/PlayerSuggestions.tsx
new file mode 100644
index 0000000..feb078d
--- /dev/null
+++ b/frontend/src/components/PlayerSuggestions.tsx
@@ -0,0 +1,44 @@
+import { Avatar } from "./Avatar";
+
+export interface PlayerSuggestion {
+ user_id: number;
+ nickname: string;
+ avatar_url?: string | null;
+}
+
+/**
+ * Выпадающие подсказки ников под инпутом. Родитель оборачивает инпут в
+ * .suggest-wrap, управляет видимостью и решает, что делать по выбору
+ * (подставить ник, перейти в профиль и т.п.).
+ */
+export function PlayerSuggestions({
+ items,
+ onPick,
+}: {
+ items: PlayerSuggestion[];
+ onPick: (item: PlayerSuggestion) => void;
+}) {
+ if (items.length === 0) return null;
+ return (
+
+ {items.map((u) => (
+
{
+ e.preventDefault();
+ onPick(u);
+ }}
+ >
+
+ {u.nickname}
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/ProfileStatsCard.tsx b/frontend/src/components/ProfileStatsCard.tsx
index 63f7a05..8651d9b 100644
--- a/frontend/src/components/ProfileStatsCard.tsx
+++ b/frontend/src/components/ProfileStatsCard.tsx
@@ -24,7 +24,7 @@ export function ProfileStatsCard({
{o.wins}
-
Winrate
+
Винрейт
{pct(o.win_rate)}
diff --git a/frontend/src/components/Switch.tsx b/frontend/src/components/Switch.tsx
new file mode 100644
index 0000000..435878c
--- /dev/null
+++ b/frontend/src/components/Switch.tsx
@@ -0,0 +1,27 @@
+/**
+ * Тумблер вкл/выкл (стили .switch в global.css) — единый контрол для булевых опций
+ * (дополнения группы, фильтры, флаги). Вместо нативных чекбоксов.
+ */
+export function Switch({
+ checked,
+ disabled,
+ onChange,
+}: {
+ checked: boolean;
+ disabled?: boolean;
+ onChange?: (checked: boolean) => void;
+}) {
+ return (
+
+ onChange?.(e.target.checked)}
+ />
+
+
+
+
+ );
+}
diff --git a/frontend/src/domain/format.ts b/frontend/src/domain/format.ts
index a9f93b8..5885c61 100644
--- a/frontend/src/domain/format.ts
+++ b/frontend/src/domain/format.ts
@@ -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);
@@ -28,6 +37,14 @@ export function formatTime(iso: string | null | undefined): string {
}
}
+// «Дата игры» приходит date-only строкой (YYYY-MM-DD) — просто переставляем части,
+// без сдвига пояса (бэкенд уже посчитал дату в поясе приложения).
+export function formatDate(iso: string | null | undefined): string {
+ if (!iso) return "—";
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
+ return m ? `${m[3]}.${m[2]}.${m[1]}` : iso;
+}
+
export function formatDateTime(iso: string | null | undefined): string {
if (!iso) return "—";
try {
diff --git a/frontend/src/hooks/groups.ts b/frontend/src/hooks/groups.ts
index eeaa183..27a3132 100644
--- a/frontend/src/hooks/groups.ts
+++ b/frontend/src/hooks/groups.ts
@@ -75,6 +75,26 @@ export function useCreateGroup() {
});
}
+export function useRenameGroup(groupId: number) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: async (name: string) =>
+ unwrap(
+ await api.PATCH("/api/groups/{group_id}", {
+ params: { path: { group_id: groupId } },
+ body: { name },
+ }),
+ ),
+ onSuccess: () => {
+ // Имя группы видно в шапке группы, переключателе групп (me), на главной.
+ qc.invalidateQueries({ queryKey: qk.group(groupId) });
+ qc.invalidateQueries({ queryKey: qk.groups });
+ qc.invalidateQueries({ queryKey: qk.me });
+ qc.invalidateQueries({ queryKey: qk.home });
+ },
+ });
+}
+
export function useSetGroupExpansions(groupId: number) {
const qc = useQueryClient();
return useMutation({
diff --git a/frontend/src/hooks/users.ts b/frontend/src/hooks/users.ts
new file mode 100644
index 0000000..b52cf1b
--- /dev/null
+++ b/frontend/src/hooks/users.ts
@@ -0,0 +1,30 @@
+import { keepPreviousData, useQuery } from "@tanstack/react-query";
+import { useEffect, useState } from "react";
+
+import { api, unwrap } from "../api/client";
+import { qk } from "../api/queryKeys";
+
+/** Значение с задержкой: не дёргаем поиск на каждый введённый символ. */
+function useDebounced
(value: T, ms = 250): T {
+ const [debounced, setDebounced] = useState(value);
+ useEffect(() => {
+ const t = setTimeout(() => setDebounced(value), ms);
+ return () => clearTimeout(t);
+ }, [value, ms]);
+ return debounced;
+}
+
+/** Подсказки по нику среди всех активных игроков (GET /api/users/search). */
+export function useUserSearch(q: string, limit = 3) {
+ const dq = useDebounced(q.trim());
+ return useQuery({
+ queryKey: qk.userSearch(dq, limit),
+ enabled: dq.length > 0,
+ // Список не мигает пустым между буквами; при пустом q данные остаются —
+ // видимость выпадашки родитель гейтит по q, а не по data.
+ placeholderData: keepPreviousData,
+ staleTime: 30_000,
+ queryFn: async () =>
+ unwrap(await api.GET("/api/users/search", { params: { query: { q: dq, limit } } })),
+ });
+}
diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx
index 7c8a336..14aac89 100644
--- a/frontend/src/pages/AccountPage.tsx
+++ b/frontend/src/pages/AccountPage.tsx
@@ -145,7 +145,7 @@ export function AccountPage() {
setNickname(e.target.value)} placeholder={me.nickname} />
- ОК
+ Сохранить
@@ -153,7 +153,7 @@ export function AccountPage() {
{stats &&
}
-
+
Выйти
diff --git a/frontend/src/pages/CreateMatchPage.tsx b/frontend/src/pages/CreateMatchPage.tsx
index 5e005c6..01f0aa9 100644
--- a/frontend/src/pages/CreateMatchPage.tsx
+++ b/frontend/src/pages/CreateMatchPage.tsx
@@ -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) {
@@ -236,8 +236,8 @@ export function CreateMatchPage() {
{error && {error}
}
diff --git a/frontend/src/pages/GroupPage.tsx b/frontend/src/pages/GroupPage.tsx
index bb01393..d2bce5c 100644
--- a/frontend/src/pages/GroupPage.tsx
+++ b/frontend/src/pages/GroupPage.tsx
@@ -1,13 +1,27 @@
+import { UserPlus } from "lucide-react";
+import { useState } from "react";
import { useNavigate } from "react-router-dom";
+import { ApiError } from "../api/client";
import { CreateGroupForm } from "../components/CreateGroupForm";
import { GroupInvitations } from "../components/GroupInvitations";
import { GroupSwitcher } from "../components/GroupSwitcher";
import { Leaderboard } from "../components/Leaderboard";
import { MatchListView } from "../components/MatchList";
+import { PlayerSuggestions } from "../components/PlayerSuggestions";
import { Spinner } from "../components/Spinner";
+import { MAX_GROUP_SIZE } from "../domain/constants";
+import { formatDate } from "../domain/format";
+import { useToast } from "../context/ToastContext";
import { useMe } from "../hooks/auth";
-import { useGroup, useGroupMatches, useGroupStats } from "../hooks/groups";
+import {
+ useGroup,
+ useGroupMatches,
+ useGroupMembers,
+ useGroupStats,
+ useInviteMember,
+} from "../hooks/groups";
+import { useUserSearch } from "../hooks/users";
export function GroupPage() {
const { data: me, isLoading } = useMe();
@@ -17,9 +31,37 @@ export function GroupPage() {
const { data: group } = useGroup(groupId);
const { data: stats } = useGroupStats(groupId);
const { data: matches } = useGroupMatches(groupId);
+ const { data: members } = useGroupMembers(groupId);
+ const inviteMember = useInviteMember();
+ const toast = useToast();
+ const [newNick, setNewNick] = useState("");
+ const [nickFocused, setNickFocused] = useState(false);
+ // После подстановки ника из подсказки список прячем: подставленный ник сам
+ // совпадает с собой и без флага выпадашка вернулась бы сразу.
+ const [nickPicked, setNickPicked] = useState(false);
+ // limit с запасом: уже состоящих в группе отфильтруем ниже, до 3 подсказок.
+ const { data: foundUsers } = useUserSearch(newNick, 10);
if (isLoading) return ;
+ const invite = async () => {
+ const nick = newNick.trim();
+ if (!nick || !groupId) return;
+ try {
+ await inviteMember.mutateAsync({ groupId, nickname: nick });
+ setNewNick("");
+ toast.show("Приглашение отправлено");
+ } catch (e) {
+ toast.show(e instanceof ApiError ? e.message : "Ошибка");
+ }
+ };
+
+ // Подсказки: уже состоящих в группе не предлагаем, показываем до 3.
+ const memberIds = new Set((members ?? []).map((m) => m.user_id));
+ const suggestions = (foundUsers ?? []).filter((u) => !memberIds.has(u.user_id)).slice(0, 3);
+ const showSuggest =
+ nickFocused && !nickPicked && newNick.trim().length > 0 && suggestions.length > 0;
+
if (!groupId) {
return (
@@ -49,14 +91,14 @@ export function GroupPage() {
Партий: {stats?.total_matches ?? 0}
- {stats?.last_match_at ? ` · последняя: ${stats.last_match_at}` : ""}
+ {stats?.last_match_at ? ` · последняя: ${formatDate(stats.last_match_at)}` : ""}
-
Статистика игроков
+
Список игроков
{stats && (
)}
+ {/* Приглашение нового игрока — здесь, рядом со списком (не в настройках). */}
+ {(members ?? []).length >= MAX_GROUP_SIZE ? (
+
+ Достигнут максимум участников ({MAX_GROUP_SIZE}).
+
+ ) : (
+
+ )}
diff --git a/frontend/src/pages/GroupSettingsPage.tsx b/frontend/src/pages/GroupSettingsPage.tsx
index 31a4f5f..2166844 100644
--- a/frontend/src/pages/GroupSettingsPage.tsx
+++ b/frontend/src/pages/GroupSettingsPage.tsx
@@ -1,12 +1,21 @@
-import { Trash2, UserPlus } from "lucide-react";
+import { Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { ApiError } from "../api/client";
+import { ConfirmDialog } from "../components/ConfirmDialog";
+import { PlayerLink } from "../components/PlayerLink";
import { Spinner } from "../components/Spinner";
+import { Switch } from "../components/Switch";
import { MAX_GROUP_SIZE } from "../domain/constants";
import { useToast } from "../context/ToastContext";
import { useMe } from "../hooks/auth";
-import { useGroup, useGroupMembers, useInviteMember, useRemoveMember, useSetGroupExpansions } from "../hooks/groups";
+import {
+ useGroup,
+ useGroupMembers,
+ useRemoveMember,
+ useRenameGroup,
+ useSetGroupExpansions,
+} from "../hooks/groups";
import { useExpansions } from "../hooks/reference";
export function GroupSettingsPage() {
@@ -16,12 +25,14 @@ export function GroupSettingsPage() {
const { data: expansions } = useExpansions();
const { data: members } = useGroupMembers(groupId);
const setExpansions = useSetGroupExpansions(groupId ?? 0);
- const inviteMember = useInviteMember();
+ const renameGroup = useRenameGroup(groupId ?? 0);
const removeMember = useRemoveMember(groupId ?? 0);
const toast = useToast();
const [selected, setSelected] = useState>(new Set());
- const [newNick, setNewNick] = useState("");
+ // Название редактируется локально; до первого ввода берём значение из группы.
+ const [name, setName] = useState(null);
+ const [removeTarget, setRemoveTarget] = useState<{ userId: number; nickname: string } | null>(null);
useEffect(() => {
if (group) setSelected(new Set(group.expansion_ids));
@@ -38,22 +49,24 @@ export function GroupSettingsPage() {
return next;
});
- const saveExpansions = async () => {
+ const nameValue = name ?? group.name;
+
+ const saveName = async () => {
+ const trimmed = nameValue.trim();
+ if (!trimmed || trimmed === group.name) return;
try {
- await setExpansions.mutateAsync([...selected]);
- toast.show("Дополнения сохранены");
+ await renameGroup.mutateAsync(trimmed);
+ setName(null);
+ toast.show("Название сохранено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
- const add = async () => {
- const nick = newNick.trim();
- if (!nick) return;
+ const saveExpansions = async () => {
try {
- await inviteMember.mutateAsync({ groupId: group.id, nickname: nick });
- setNewNick("");
- toast.show("Приглашение отправлено");
+ await setExpansions.mutateAsync([...selected]);
+ toast.show("Дополнения сохранены");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
@@ -69,27 +82,43 @@ export function GroupSettingsPage() {
return (
+
+
Название
+
+ setName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") saveName();
+ }}
+ />
+
+ Сохранить
+
+
+
+
+ {/* Приглашение нового игрока — на странице группы («Список игроков»). */}
Участники ({(members ?? []).length}/{MAX_GROUP_SIZE})
- {isOwner && (members ?? []).length >= MAX_GROUP_SIZE && (
-
Достигнут максимум участников ({MAX_GROUP_SIZE}).
- )}
- {isOwner && (members ?? []).length < MAX_GROUP_SIZE && (
-
- setNewNick(e.target.value)}
- />
-
-
-
-
- )}
{(members ?? []).map((m) => (
-
- {m.nickname} · {m.role}
-
+ {/* Кликабельна вся левая часть строки, не только текст ника. */}
+
+ {m.nickname}{" "}
+ · {m.role === "owner" ? "владелец" : "участник"}
+
{isOwner && m.role !== "owner" && (
-
remove(m.user_id)}>
+ setRemoveTarget({ userId: m.user_id, nickname: m.nickname })}
+ >
)}
@@ -130,6 +150,21 @@ export function GroupSettingsPage() {
))}
+
+
setRemoveTarget(null)}
+ onConfirm={() => {
+ if (!removeTarget) return;
+ const userId = removeTarget.userId;
+ setRemoveTarget(null);
+ remove(userId);
+ }}
+ />
);
}
diff --git a/frontend/src/pages/HelpPage.tsx b/frontend/src/pages/HelpPage.tsx
index 06d0ff1..10fbf77 100644
--- a/frontend/src/pages/HelpPage.tsx
+++ b/frontend/src/pages/HelpPage.tsx
@@ -5,9 +5,9 @@
export function HelpPage() {
return (
+ {/* Название страницы уже в верхней панели — в теле только пояснение. */}
-
Справка
-
+
Здесь со временем появятся правила, подсказки и пояснения. Пока — о том, как
считается рейтинг игроков.
@@ -55,7 +55,7 @@ export function HelpPage() {
Попадание в топ
- Чтобы попасть в ранжированный топ, нужно сыграть минимум 3 партии . До этого
+ Чтобы попасть в ранжированный топ, нужно сыграть минимум 5 партий . До этого
игрок показывается в блоке «Новички» — предварительно, без места в рейтинге.
@@ -63,7 +63,7 @@ export function HelpPage() {
Другие показатели
- Винрейт — доля партий, в которых игрок занял 1-е место.
+ Винрейт (WR) — доля партий, в которых игрок занял 1-е место.
Среднее место — среднее по завершённым партиям.
При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту
diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx
index 89aad8a..016ed05 100644
--- a/frontend/src/pages/HomePage.tsx
+++ b/frontend/src/pages/HomePage.tsx
@@ -15,15 +15,15 @@ export function HomePage() {
return (
- {/* Карточка кликабельна целиком → общая статистика; клик по нику внутри
- ведёт в профиль (Leaderboard останавливает всплытие на ссылках ников). */}
+ {/* Карточка кликабельна целиком → общая статистика; ники внутри — просто
+ текст, ссылки на профили есть в самой общей статистике. */}
navigate("/stats")}>
Топ игроков
Вся статистика →
-
+
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
index cb82bc0..f2cc532 100644
--- a/frontend/src/pages/LoginPage.tsx
+++ b/frontend/src/pages/LoginPage.tsx
@@ -154,7 +154,7 @@ function TelegramNicknamePicker({
{error && {error}
}
- Назад
+ Отмена
);
diff --git a/frontend/src/pages/MatchDetailPage.tsx b/frontend/src/pages/MatchDetailPage.tsx
index 303dda0..f452107 100644
--- a/frontend/src/pages/MatchDetailPage.tsx
+++ b/frontend/src/pages/MatchDetailPage.tsx
@@ -4,8 +4,9 @@ import { useNavigate, useParams } from "react-router-dom";
import { ApiError } from "../api/client";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { MatchMedia } from "../components/MatchMedia";
+import { PlayerLink } from "../components/PlayerLink";
import { Spinner } from "../components/Spinner";
-import { formatDuration, formatTime } from "../domain/format";
+import { formatDate, formatDuration, formatTime } from "../domain/format";
import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
import { useToast } from "../context/ToastContext";
import {
@@ -119,9 +120,9 @@ export function MatchDetailPage() {
-
{match.played_at}
+
{formatDate(match.played_at)}
{inProgress ? (
-
идёт
+
идёт
) : (
{formatDuration(match.duration_minutes)}
)}
@@ -145,7 +146,7 @@ export function MatchDetailPage() {
{sorted.map((p) => (
-
+
{p.nickname}
· {p.faction_name}
{p.was_random && 🎲 }
-
+
{p.comment &&
{p.comment}
}
))}
@@ -181,11 +182,11 @@ export function MatchDetailPage() {
Состав
{match.participants.map((p) => (
-
+
{p.nickname}
· {p.faction_name}
{p.was_random && 🎲 }
-
+
))}
@@ -260,8 +261,7 @@ export function MatchDetailPage() {
{error &&
{error}
}
@@ -273,11 +273,7 @@ export function MatchDetailPage() {
)}
{canModify && (
- setConfirmRemove(true)}
- >
+ setConfirmRemove(true)}>
{inProgress ? "Отменить партию" : "Удалить партию"}
)}
@@ -292,7 +288,6 @@ export function MatchDetailPage() {
: "Сыгранная партия и её результаты будут удалены. Действие необратимо."
}
confirmLabel={inProgress ? "Отменить партию" : "Удалить"}
- cancelLabel="Назад"
onCancel={() => setConfirmRemove(false)}
onConfirm={() => {
setConfirmRemove(false);
diff --git a/frontend/src/pages/OverallStatsPage.tsx b/frontend/src/pages/OverallStatsPage.tsx
index 75e2134..74d42d2 100644
--- a/frontend/src/pages/OverallStatsPage.tsx
+++ b/frontend/src/pages/OverallStatsPage.tsx
@@ -1,7 +1,7 @@
import { useMemo, useState } from "react";
-import { Link } from "react-router-dom";
import { Avatar } from "../components/Avatar";
+import { PlayerLink } from "../components/PlayerLink";
import { Spinner } from "../components/Spinner";
import type { LeaderboardEntry } from "../domain/types";
import { useLeaderboard } from "../hooks/stats";
@@ -42,8 +42,8 @@ export function OverallStatsPage() {
const provisional = filterSort(data.provisional);
const arrow = (k: SortKey) => (k === sortKey ? (dir === 1 ? " ↑" : " ↓") : "");
- const row = (e: LeaderboardEntry) => (
-
+ const row = (e: LeaderboardEntry, isProvisional = false) => (
+
{e.rank ?? "—"}
@@ -52,14 +52,16 @@ export function OverallStatsPage() {
{e.games}
{e.wins}
{pct(e.win_rate)}
- {e.score ?? "—"}
-
+
+ {e.score ?? "—"}
+
+
);
return (
+ {/* Название страницы уже в верхней панели — в теле сразу поиск. */}
-
Общая статистика
setQ(e.target.value)} />
@@ -77,14 +79,14 @@ export function OverallStatsPage() {
Никого не найдено.
) : (
- entries.map(row)
+ entries.map((e) => row(e))
)}
{provisional.length > 0 && (
Новички (меньше {data.min_games} игр)
- {provisional.map(row)}
+ {provisional.map((e) => row(e, true))}
)}
diff --git a/frontend/src/pages/PublicProfilePage.tsx b/frontend/src/pages/PublicProfilePage.tsx
index 44098c4..6b5a843 100644
--- a/frontend/src/pages/PublicProfilePage.tsx
+++ b/frontend/src/pages/PublicProfilePage.tsx
@@ -64,8 +64,7 @@ export function PublicProfilePage() {
{groups.length === 1 ? (
doInvite(groups[0].id)}
disabled={invite.isPending}
>
@@ -74,8 +73,7 @@ export function PublicProfilePage() {
) : (
<>
setPickOpen((v) => !v)}
>
Добавить в группу
diff --git a/frontend/src/pages/admin/AdminAccountsPage.tsx b/frontend/src/pages/admin/AdminAccountsPage.tsx
index 3876bda..1746e8f 100644
--- a/frontend/src/pages/admin/AdminAccountsPage.tsx
+++ b/frontend/src/pages/admin/AdminAccountsPage.tsx
@@ -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 : "Не удалось переименовать");
@@ -42,7 +42,7 @@ export function AdminAccountsPage() {
return (
-
Аккаунты
+ {/* Название раздела уже в активной вкладке админки. */}
{(users ?? []).map((u) => (
@@ -63,7 +63,7 @@ export function AdminAccountsPage() {
disabled={update.isPending || !editName.trim() || editName.trim() === u.nickname}
onClick={saveName}
>
- ОК
+ Сохранить
setEditId(null)}>
Отмена
@@ -72,7 +72,7 @@ export function AdminAccountsPage() {
) : (
-
{u.nickname} {u.role === "admin" &&
admin }
+
{u.nickname} {u.role === "admin" &&
админ }
#{u.id} · {u.auth_provider} · {u.is_active ? "активен" : "отключён"}
diff --git a/frontend/src/pages/admin/AdminAchievementsPage.tsx b/frontend/src/pages/admin/AdminAchievementsPage.tsx
index 99016b6..c7b3d2d 100644
--- a/frontend/src/pages/admin/AdminAchievementsPage.tsx
+++ b/frontend/src/pages/admin/AdminAchievementsPage.tsx
@@ -2,6 +2,7 @@ import { useRef, useState } from "react";
import { ApiError } from "../../api/client";
import { Avatar } from "../../components/Avatar";
+import { ConfirmDialog } from "../../components/ConfirmDialog";
import { Spinner } from "../../components/Spinner";
import { useToast } from "../../context/ToastContext";
import {
@@ -87,6 +88,7 @@ function AchievementCard({ ach }: { ach: AchievementRead }) {
const toast = useToast();
const fileRef = useRef
(null);
const [editing, setEditing] = useState(false);
+ const [confirmRemove, setConfirmRemove] = useState(false);
const [name, setName] = useState(ach.name);
const [description, setDescription] = useState(ach.description);
const [condition, setCondition] = useState(ach.condition);
@@ -117,7 +119,6 @@ function AchievementCard({ ach }: { ach: AchievementRead }) {
};
const remove = async () => {
- if (!window.confirm(`Удалить ачивку «${ach.name}»?`)) return;
try {
await del.mutateAsync(ach.slug);
toast.show("Удалено");
@@ -175,7 +176,11 @@ function AchievementCard({ ach }: { ach: AchievementRead }) {
setEditing(true)}>
Изменить
-
+ setConfirmRemove(true)}
+ disabled={del.isPending}
+ >
Удалить
)}
+
+
setConfirmRemove(false)}
+ onConfirm={() => {
+ setConfirmRemove(false);
+ remove();
+ }}
+ />
);
}
diff --git a/frontend/src/pages/admin/AdminFactionsPage.tsx b/frontend/src/pages/admin/AdminFactionsPage.tsx
index a5467b0..dc955e7 100644
--- a/frontend/src/pages/admin/AdminFactionsPage.tsx
+++ b/frontend/src/pages/admin/AdminFactionsPage.tsx
@@ -30,8 +30,8 @@ export function AdminFactionsPage() {
return (
-
Фракции
-
+ {/* Название раздела уже в активной вкладке админки. */}
+
Переименование меняет название фракции во всех партиях и статистике.
@@ -48,7 +48,7 @@ export function AdminFactionsPage() {
disabled={!names[f.id] || names[f.id] === f.name_ru}
onClick={() => save(f.id)}
>
- ОК
+ Сохранить
diff --git a/frontend/src/pages/admin/AdminLoginPage.tsx b/frontend/src/pages/admin/AdminLoginPage.tsx
index 1c40820..4a11f95 100644
--- a/frontend/src/pages/admin/AdminLoginPage.tsx
+++ b/frontend/src/pages/admin/AdminLoginPage.tsx
@@ -40,7 +40,7 @@ export function AdminLoginPage() {
setPassword(e.target.value)} />
{error &&
{error}
}
-
+
Войти
diff --git a/frontend/src/pages/admin/AdminLogsPage.tsx b/frontend/src/pages/admin/AdminLogsPage.tsx
index 3569f01..4f14852 100644
--- a/frontend/src/pages/admin/AdminLogsPage.tsx
+++ b/frontend/src/pages/admin/AdminLogsPage.tsx
@@ -17,7 +17,7 @@ export function AdminLogsPage() {
return (
-
Журнал событий
+ {/* Название раздела уже в активной вкладке админки. */}
setAction(e.target.value)}>
{ACTIONS.map((a) => (
@@ -59,7 +59,7 @@ export function AdminLogsPage() {
)}
))}
- {(data?.items ?? []).length === 0 &&
Событий нет.
}
+ {(data?.items ?? []).length === 0 &&
Событий пока нет.
}
)}
diff --git a/frontend/src/pages/admin/AdminMatchEdit.tsx b/frontend/src/pages/admin/AdminMatchEdit.tsx
index 07eba02..3a3b96f 100644
--- a/frontend/src/pages/admin/AdminMatchEdit.tsx
+++ b/frontend/src/pages/admin/AdminMatchEdit.tsx
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
import { ApiError } from "../../api/client";
import { MatchMedia } from "../../components/MatchMedia";
import { Spinner } from "../../components/Spinner";
+import { Switch } from "../../components/Switch";
import { WIN_REASONS, type WinReason } from "../../domain/winReasons";
import { useToast } from "../../context/ToastContext";
import {
@@ -141,12 +142,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
/>
- upd(i, { eliminated: e.target.checked })}
- />
+ upd(i, { eliminated: v })} />
выбыл
@@ -190,7 +186,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
{error &&
{error}
}
-
+
Сохранить изменения
>
diff --git a/frontend/src/pages/admin/AdminRecordsPage.tsx b/frontend/src/pages/admin/AdminRecordsPage.tsx
index 5364427..6f762d4 100644
--- a/frontend/src/pages/admin/AdminRecordsPage.tsx
+++ b/frontend/src/pages/admin/AdminRecordsPage.tsx
@@ -1,7 +1,11 @@
import { Pencil, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
+import { ApiError } from "../../api/client";
+import { ConfirmDialog } from "../../components/ConfirmDialog";
+import { formatDate, plural } from "../../domain/format";
import { Spinner } from "../../components/Spinner";
+import { Switch } from "../../components/Switch";
import { useToast } from "../../context/ToastContext";
import {
useAdminDeleteMatch,
@@ -19,6 +23,7 @@ export function AdminRecordsPage() {
const [factionFilter, setFactionFilter] = useState(null);
const [grouped, setGrouped] = useState(true);
const [editId, setEditId] = useState(null);
+ const [deleteTarget, setDeleteTarget] = useState(null);
const { data: groups } = useAdminGroups();
const { data: users } = useAdminUsers();
@@ -50,23 +55,18 @@ export function AdminRecordsPage() {
-
#{m.id} · {m.played_at}
+
#{m.id} · {formatDate(m.played_at)}
{!grouped &&
· {m.group_name} }
- {m.player_count} игр. · автор {m.created_by}
+ {m.player_count} {plural(m.player_count, "игрок", "игрока", "игроков")} · автор{" "}
+ {m.created_by}
setEditId(m.id)}>
-
{
- await del.mutateAsync(m.id).catch(() => {});
- toast.show("Удалено");
- }}
- >
+ setDeleteTarget(m)}>
@@ -112,12 +112,7 @@ export function AdminRecordsPage() {
))}
- setGrouped(e.target.checked)}
- />
+
Группировать по группам
@@ -126,7 +121,7 @@ export function AdminRecordsPage() {
{isLoading ? (
) : (matches ?? []).length === 0 ? (
-
Записей нет.
+
Записей пока нет.
) : grouped ? (
byGroup.map(([gid, { name, items }]) => (
@@ -141,6 +136,25 @@ export function AdminRecordsPage() {
)}
{editId != null &&
setEditId(null)} />}
+
+ setDeleteTarget(null)}
+ onConfirm={async () => {
+ if (!deleteTarget) return;
+ setDeleteTarget(null);
+ try {
+ await del.mutateAsync(deleteTarget.id);
+ toast.show("Удалено");
+ } catch (e) {
+ toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
+ }
+ }}
+ />
);
}
diff --git a/frontend/src/pages/admin/DevDeleteAccountButton.tsx b/frontend/src/pages/admin/DevDeleteAccountButton.tsx
index 40b735e..884b137 100644
--- a/frontend/src/pages/admin/DevDeleteAccountButton.tsx
+++ b/frontend/src/pages/admin/DevDeleteAccountButton.tsx
@@ -1,7 +1,9 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
import { ApiError, api, unwrap } from "../../api/client";
import { qk } from "../../api/queryKeys";
+import { ConfirmDialog } from "../../components/ConfirmDialog";
import { useToast } from "../../context/ToastContext";
/**
@@ -21,6 +23,7 @@ export function DevDeleteAccountButton({
}) {
const qc = useQueryClient();
const toast = useToast();
+ const [confirmOpen, setConfirmOpen] = useState(false);
const del = useMutation({
mutationFn: async () =>
unwrap(
@@ -32,29 +35,34 @@ export function DevDeleteAccountButton({
});
return (
-
{
- if (
- !window.confirm(
- `Удалить аккаунт «${nickname}»? Партии сохранятся, но игрок будет ` +
- `вычеркнут из них. Действие необратимо.`,
- )
- )
- return;
- try {
- await del.mutateAsync();
- toast.show("Аккаунт удалён");
- } catch (e) {
- toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
- }
- }}
- >
- Удалить
-
+ <>
+
setConfirmOpen(true)}
+ >
+ Удалить
+
+
setConfirmOpen(false)}
+ onConfirm={async () => {
+ setConfirmOpen(false);
+ try {
+ await del.mutateAsync();
+ toast.show("Аккаунт удалён");
+ } catch (e) {
+ toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
+ }
+ }}
+ />
+ >
);
}
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index 9937cb1..096faf8 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -71,6 +71,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger); }
.btn-danger-solid { background: var(--danger); border-color: var(--danger); color: #fff; }
.btn[disabled] { opacity: 0.5; pointer-events: none; }
+.btn-block { width: 100%; }
.row { display: flex; align-items: center; gap: 10px; }
.row-between { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
@@ -91,6 +92,8 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
border: 1px solid var(--border);
font-size: 12px;
}
+/* Бейдж незавершённой партии («идёт») */
+.badge-live { color: var(--accent-2); }
.error-text { color: var(--danger); font-size: 13px; }
@@ -286,6 +289,39 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
.notif-item-body { margin-top: 2px; }
.notif-item-time { margin-top: 4px; }
+/* Подсказки ников под инпутом (приглашение игрока и т.п.) */
+.suggest-wrap { position: relative; flex: 1; min-width: 0; }
+.suggest-list {
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ right: 0;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
+ z-index: 50;
+ overflow: hidden;
+}
+.suggest-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 8px 10px;
+ background: none;
+ border: none;
+ border-bottom: 1px solid var(--border);
+ color: var(--text);
+ font: inherit;
+ font-weight: 600;
+ text-align: left;
+ cursor: pointer;
+}
+.suggest-row:last-child { border-bottom: none; }
+.suggest-row:hover, .suggest-row:active { background: var(--surface-2); }
+.suggest-row .nick { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
/* Всплывашки сверху экрана в момент прихода */
.toast-top-wrap {
position: fixed;
diff --git a/frontend/src/styles/layout.css b/frontend/src/styles/layout.css
index 4d7d4cd..6084ee5 100644
--- a/frontend/src/styles/layout.css
+++ b/frontend/src/styles/layout.css
@@ -106,6 +106,8 @@
.lb-rank.top { color: var(--gold); }
.lb-metrics { display: flex; gap: 12px; font-size: 13px; color: var(--text-muted); }
.lb-score { font-weight: 700; color: var(--accent-2); }
+/* Рейтинг новичков (мало игр) — серебряный, не золотой. */
+.lb-score.provisional { color: var(--silver); }
/* Таблица общей статистики (сортируемая, кликабельные строки) */
.st-row, .st-head {
diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css
index 1d35adf..44a25ae 100644
--- a/frontend/src/styles/tokens.css
+++ b/frontend/src/styles/tokens.css
@@ -11,6 +11,7 @@
--success: #4caf7d;
--danger: #e0533d;
--gold: #f0c14b;
+ --silver: #c0c6cf;
/* Размеры */
--app-max-w: 480px;