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 && "✓"} ); })} {/* Плашка создания новой группы — в одном ряду логики с выбором группы. */} - 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) => ( + ))} + + ); +} 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/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(); + }} + /> + +
+
+

Дополнения

- {(expansions ?? []) - .filter((e) => !e.is_base) - .map((e) => ( - - ))} + {/* Базовые дополнения показываем, как и при создании группы: включены и заблокированы. */} + {(expansions ?? []).map((e) => ( + + ))}
{isOwner && (
+ {/* Приглашение нового игрока — на странице группы («Список игроков»). */}

Участники ({(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" && ( - )} @@ -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}

} )} @@ -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 ? ( -
)} + + 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 (
-

Журнал событий

+ {/* Название раздела уже в активной вкладке админки. */}
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}
-
@@ -112,12 +112,7 @@ export function AdminRecordsPage() { ))}
@@ -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 ( - + <> + + 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;