From cbf3cfc6e613cb33fc51a45864c0561850fe8b91 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 13 Jul 2026 17:37:19 +0300 Subject: [PATCH 01/15] =?UTF-8?q?UI:=20=D0=B5=D0=B4=D0=B8=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20PlayerLink=20=E2=80=94=20=D0=BD=D0=B8=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=BE=D0=B2=20=D0=BA=D0=BB=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=D0=B1=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=20=D0=B2=D0=BE?= =?UTF-8?q?=20=D0=B2=D1=81=D0=B5=D1=85=20=D0=BC=D0=B5=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=85=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый компонент PlayerLink (ссылка на /u/:id, кликабельно всё поле строки, stopPropagation для вложенности в кликабельные карточки). Применён: - Leaderboard и общая статистика (рефакторинг ручных Link); - страница партии: результаты и состав; - настройки группы: список участников; - список партий и незавершённые на главной (карточки button -> div, т.к. ссылки внутри button — невалидная разметка). «пригласил: X» в приглашениях остаётся текстом — в API нет id пригласившего. Co-Authored-By: Claude Fable 5 --- frontend/src/components/InProgressMatches.tsx | 15 +++++++-- frontend/src/components/Leaderboard.tsx | 16 +++------- frontend/src/components/MatchList.tsx | 13 +++++--- frontend/src/components/PlayerLink.tsx | 31 +++++++++++++++++++ frontend/src/pages/GroupSettingsPage.tsx | 6 ++-- frontend/src/pages/MatchDetailPage.tsx | 9 +++--- frontend/src/pages/OverallStatsPage.tsx | 6 ++-- 7 files changed, 68 insertions(+), 28 deletions(-) create mode 100644 frontend/src/components/PlayerLink.tsx diff --git a/frontend/src/components/InProgressMatches.tsx b/frontend/src/components/InProgressMatches.tsx index bb9917b..e6044dc 100644 --- a/frontend/src/components/InProgressMatches.tsx +++ b/frontend/src/components/InProgressMatches.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import { formatTime } from "../domain/format"; import type { HomeInProgressMatch } from "../domain/types"; +import { PlayerLink } from "./PlayerLink"; export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) { const navigate = useNavigate(); @@ -13,7 +14,9 @@ export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) {

Незавершённые партии

{items.map((m) => ( - +
))} diff --git a/frontend/src/components/Leaderboard.tsx b/frontend/src/components/Leaderboard.tsx index e5e13f2..94dfeb1 100644 --- a/frontend/src/components/Leaderboard.tsx +++ b/frontend/src/components/Leaderboard.tsx @@ -1,7 +1,6 @@ -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)}%`; @@ -9,14 +8,9 @@ function pct(v: number | null | undefined): string { function Row({ entry }: { entry: LeaderboardEntry }) { return ( - // Вся строка — ссылка на профиль. stopPropagation: на главной карточка-обёртка - // ведёт в общую статистику, а клик по строке игрока — в его профиль. - e.stopPropagation()} - > + // Вся строка — ссылка на профиль (на главной карточка-обёртка ведёт в общую + // статистику, а клик по строке игрока — в его профиль). +
{entry.rank ?? "—"}
@@ -30,7 +24,7 @@ function Row({ entry }: { entry: LeaderboardEntry }) {
{entry.score ?? "—"}
- +
); } diff --git a/frontend/src/components/MatchList.tsx b/frontend/src/components/MatchList.tsx index 7aac38b..f55a90b 100644 --- a/frontend/src/components/MatchList.tsx +++ b/frontend/src/components/MatchList.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import { formatDuration } from "../domain/format"; import { winReasonLabel } from "../domain/winReasons"; import type { MatchListItem } from "../domain/types"; +import { PlayerLink } from "./PlayerLink"; export function MatchListView({ items }: { items: MatchListItem[] }) { const navigate = useNavigate(); @@ -14,10 +15,12 @@ export function MatchListView({ items }: { items: MatchListItem[] }) { const inProgress = m.status === "in_progress"; const sorted = [...m.participants].sort((a, b) => (a.place ?? 99) - (b.place ?? 99)); return ( - + ); })} 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/pages/GroupSettingsPage.tsx b/frontend/src/pages/GroupSettingsPage.tsx index 31a4f5f..6e82776 100644 --- a/frontend/src/pages/GroupSettingsPage.tsx +++ b/frontend/src/pages/GroupSettingsPage.tsx @@ -2,6 +2,7 @@ import { Trash2, UserPlus } from "lucide-react"; import { useEffect, useState } from "react"; import { ApiError } from "../api/client"; +import { PlayerLink } from "../components/PlayerLink"; import { Spinner } from "../components/Spinner"; import { MAX_GROUP_SIZE } from "../domain/constants"; import { useToast } from "../context/ToastContext"; @@ -118,9 +119,10 @@ export function GroupSettingsPage() {
{(members ?? []).map((m) => (
- + {/* Кликабельна вся левая часть строки, не только текст ника. */} + {m.nickname} · {m.role} - + {isOwner && m.role !== "owner" && (
diff --git a/frontend/src/pages/admin/AdminRecordsPage.tsx b/frontend/src/pages/admin/AdminRecordsPage.tsx index 5364427..7fd1c7a 100644 --- a/frontend/src/pages/admin/AdminRecordsPage.tsx +++ b/frontend/src/pages/admin/AdminRecordsPage.tsx @@ -2,6 +2,7 @@ import { Pencil, Trash2 } from "lucide-react"; import { useMemo, useState } from "react"; import { Spinner } from "../../components/Spinner"; +import { Switch } from "../../components/Switch"; import { useToast } from "../../context/ToastContext"; import { useAdminDeleteMatch, @@ -112,12 +113,7 @@ export function AdminRecordsPage() { ))} From 7b61443fb95189a4307a882f1757460ca2cf9a0a Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 13 Jul 2026 17:40:53 +0300 Subject: [PATCH 03/15] =?UTF-8?q?UI:=20=D0=B5=D0=B4=D0=B8=D0=BD=D0=BE?= =?UTF-8?q?=D0=B5=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20ConfirmDialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - админка, записи: удаление партии раньше срабатывало сразу по клику на иконку и показывало «Удалено» даже при ошибке — теперь модалка подтверждения и честная обработка ошибок; - ачивки и dev-удаление аккаунта: window.confirm заменён на ConfirmDialog (в приложении принят именно он); - настройки группы: удаление участника — с подтверждением. Co-Authored-By: Claude Fable 5 --- frontend/src/pages/GroupSettingsPage.tsx | 22 +++++++- .../src/pages/admin/AdminAchievementsPage.tsx | 22 +++++++- frontend/src/pages/admin/AdminRecordsPage.tsx | 30 +++++++--- .../pages/admin/DevDeleteAccountButton.tsx | 56 +++++++++++-------- 4 files changed, 96 insertions(+), 34 deletions(-) diff --git a/frontend/src/pages/GroupSettingsPage.tsx b/frontend/src/pages/GroupSettingsPage.tsx index c71a0b8..bca3365 100644 --- a/frontend/src/pages/GroupSettingsPage.tsx +++ b/frontend/src/pages/GroupSettingsPage.tsx @@ -2,6 +2,7 @@ import { Trash2, UserPlus } 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"; @@ -24,6 +25,7 @@ export function GroupSettingsPage() { const [selected, setSelected] = useState>(new Set()); const [newNick, setNewNick] = useState(""); + const [removeTarget, setRemoveTarget] = useState<{ userId: number; nickname: string } | null>(null); useEffect(() => { if (group) setSelected(new Set(group.expansion_ids)); @@ -121,7 +123,10 @@ export function GroupSettingsPage() { {m.nickname} · {m.role} {isOwner && m.role !== "owner" && ( - )} @@ -129,6 +134,21 @@ export function GroupSettingsPage() { ))} + + setRemoveTarget(null)} + onConfirm={() => { + if (!removeTarget) return; + const userId = removeTarget.userId; + setRemoveTarget(null); + remove(userId); + }} + /> ); } 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 }) { - )} + + setConfirmRemove(false)} + onConfirm={() => { + setConfirmRemove(false); + remove(); + }} + /> ); } diff --git a/frontend/src/pages/admin/AdminRecordsPage.tsx b/frontend/src/pages/admin/AdminRecordsPage.tsx index 7fd1c7a..8bbab19 100644 --- a/frontend/src/pages/admin/AdminRecordsPage.tsx +++ b/frontend/src/pages/admin/AdminRecordsPage.tsx @@ -1,6 +1,8 @@ import { Pencil, Trash2 } from "lucide-react"; import { useMemo, useState } from "react"; +import { ApiError } from "../../api/client"; +import { ConfirmDialog } from "../../components/ConfirmDialog"; import { Spinner } from "../../components/Spinner"; import { Switch } from "../../components/Switch"; import { useToast } from "../../context/ToastContext"; @@ -20,6 +22,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(); @@ -61,13 +64,7 @@ export function AdminRecordsPage() { - @@ -137,6 +134,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 : "Не удалось удалить"); + } + }} + /> + ); } From 002c65785564f3239164991e75bc78a1d8d79a58 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 13 Jul 2026 17:42:05 +0300 Subject: [PATCH 04/15] =?UTF-8?q?UI:=20=D0=B4=D0=B0=D1=82=D1=8B=20=D0=B8?= =?UTF-8?q?=D0=B3=D1=80=20=D0=B2=20=D0=B5=D0=B4=D0=B8=D0=BD=D0=BE=D0=BC=20?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D0=B5=20=D0=94=D0=94.?= =?UTF-8?q?=D0=9C=D0=9C.=D0=93=D0=93=D0=93=D0=93=20=D0=B2=D0=BC=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=BE=20=D1=81=D1=8B=D1=80=D1=8B=D1=85=20ISO-?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый formatDate в domain/format.ts для date-only строк (played_at, last_match_at). Применён: список партий, заголовок страницы партии (раньше там было «2026-07-13»), записи в админке, «последняя партия» на странице группы. Co-Authored-By: Claude Fable 5 --- frontend/src/components/MatchList.tsx | 4 ++-- frontend/src/domain/format.ts | 8 ++++++++ frontend/src/pages/GroupPage.tsx | 3 ++- frontend/src/pages/MatchDetailPage.tsx | 4 ++-- frontend/src/pages/admin/AdminRecordsPage.tsx | 3 ++- 5 files changed, 16 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/MatchList.tsx b/frontend/src/components/MatchList.tsx index f55a90b..1e86f14 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"; import { PlayerLink } from "./PlayerLink"; @@ -24,7 +24,7 @@ export function MatchListView({ items }: { items: MatchListItem[] }) { onClick={() => navigate(`/match/${m.id}`)} >
- {m.played_at} + {formatDate(m.played_at)} {inProgress ? ( идёт ) : ( diff --git a/frontend/src/domain/format.ts b/frontend/src/domain/format.ts index a9f93b8..8a4b6cd 100644 --- a/frontend/src/domain/format.ts +++ b/frontend/src/domain/format.ts @@ -28,6 +28,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/pages/GroupPage.tsx b/frontend/src/pages/GroupPage.tsx index bb01393..8ef89ad 100644 --- a/frontend/src/pages/GroupPage.tsx +++ b/frontend/src/pages/GroupPage.tsx @@ -6,6 +6,7 @@ import { GroupSwitcher } from "../components/GroupSwitcher"; import { Leaderboard } from "../components/Leaderboard"; import { MatchListView } from "../components/MatchList"; import { Spinner } from "../components/Spinner"; +import { formatDate } from "../domain/format"; import { useMe } from "../hooks/auth"; import { useGroup, useGroupMatches, useGroupStats } from "../hooks/groups"; @@ -49,7 +50,7 @@ export function GroupPage() {

Партий: {stats?.total_matches ?? 0} - {stats?.last_match_at ? ` · последняя: ${stats.last_match_at}` : ""} + {stats?.last_match_at ? ` · последняя: ${formatDate(stats.last_match_at)}` : ""}

diff --git a/frontend/src/pages/MatchDetailPage.tsx b/frontend/src/pages/MatchDetailPage.tsx index 6e13008..d8ebc67 100644 --- a/frontend/src/pages/MatchDetailPage.tsx +++ b/frontend/src/pages/MatchDetailPage.tsx @@ -6,7 +6,7 @@ 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 { @@ -120,7 +120,7 @@ export function MatchDetailPage() {
-

{match.played_at}

+

{formatDate(match.played_at)}

{inProgress ? ( идёт ) : ( diff --git a/frontend/src/pages/admin/AdminRecordsPage.tsx b/frontend/src/pages/admin/AdminRecordsPage.tsx index 8bbab19..e4de2f4 100644 --- a/frontend/src/pages/admin/AdminRecordsPage.tsx +++ b/frontend/src/pages/admin/AdminRecordsPage.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from "react"; import { ApiError } from "../../api/client"; import { ConfirmDialog } from "../../components/ConfirmDialog"; +import { formatDate } from "../../domain/format"; import { Spinner } from "../../components/Spinner"; import { Switch } from "../../components/Switch"; import { useToast } from "../../context/ToastContext"; @@ -54,7 +55,7 @@ export function AdminRecordsPage() {
- #{m.id} · {m.played_at} + #{m.id} · {formatDate(m.played_at)} {!grouped && · {m.group_name}}
{m.player_count} игр. · автор {m.created_by} From e38555b74dfea7707bacb5eb12526ca97fd606e9 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 13 Jul 2026 17:44:08 +0300 Subject: [PATCH 05/15] =?UTF-8?q?UI:=20=D1=81=D0=BE=D0=B3=D0=BB=D0=B0?= =?UTF-8?q?=D1=81=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=81=D1=82=D1=8B=20=D0=B8=20=D1=82=D0=B5=D1=80=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD=D0=BE=D0=BB=D0=BE=D0=B3=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - «Winrate» -> «Винрейт» в карточке статистики; в справке добавлена расшифровка «Винрейт (WR)» — WR остаётся в компактных таблицах; - кнопки сохранения: «ОК» -> «Сохранить» (аккаунт, фракции, аккаунты в админке); отмена в модалках/формах — везде «Отмена», не «Назад»; - пустые состояния приведены к шаблону «X пока нет.»; - роли участников группы переведены: owner/member -> владелец/участник, бейдж admin -> «админ»; «Имя обновлено» -> «Ник обновлён»; - «N игр.» в значении числа игроков -> «N игроков» со склонением (новый хелпер plural в domain/format.ts); - «минимум 2 участника» -> «минимум 2 игрока» (в партии — игроки, в группе — участники). Co-Authored-By: Claude Fable 5 --- frontend/src/components/InProgressMatches.tsx | 5 +++-- frontend/src/components/Leaderboard.tsx | 2 +- frontend/src/components/NotificationPanel.tsx | 2 +- frontend/src/components/ProfileStatsCard.tsx | 2 +- frontend/src/domain/format.ts | 9 +++++++++ frontend/src/pages/AccountPage.tsx | 2 +- frontend/src/pages/CreateMatchPage.tsx | 2 +- frontend/src/pages/GroupSettingsPage.tsx | 3 ++- frontend/src/pages/HelpPage.tsx | 2 +- frontend/src/pages/LoginPage.tsx | 2 +- frontend/src/pages/MatchDetailPage.tsx | 1 - frontend/src/pages/admin/AdminAccountsPage.tsx | 6 +++--- frontend/src/pages/admin/AdminFactionsPage.tsx | 2 +- frontend/src/pages/admin/AdminLogsPage.tsx | 2 +- frontend/src/pages/admin/AdminRecordsPage.tsx | 7 ++++--- 15 files changed, 30 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/InProgressMatches.tsx b/frontend/src/components/InProgressMatches.tsx index e6044dc..750232e 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"; import { PlayerLink } from "./PlayerLink"; @@ -37,7 +37,8 @@ export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) { идёт
- {formatTime(m.started_at)} · {m.player_count} игр. + {formatTime(m.started_at)} · {m.player_count}{" "} + {plural(m.player_count, "игрок", "игрока", "игроков")}
Пока нет сыгранных партий.
; + return
Сыгранных партий пока нет.
; } return (
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/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/domain/format.ts b/frontend/src/domain/format.ts index 8a4b6cd..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); diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index 7c8a336..004fbee 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} />
diff --git a/frontend/src/pages/CreateMatchPage.tsx b/frontend/src/pages/CreateMatchPage.tsx index 5e005c6..0d443ae 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) { diff --git a/frontend/src/pages/GroupSettingsPage.tsx b/frontend/src/pages/GroupSettingsPage.tsx index bca3365..be2b233 100644 --- a/frontend/src/pages/GroupSettingsPage.tsx +++ b/frontend/src/pages/GroupSettingsPage.tsx @@ -120,7 +120,8 @@ export function GroupSettingsPage() {
{/* Кликабельна вся левая часть строки, не только текст ника. */} - {m.nickname} · {m.role} + {m.nickname}{" "} + · {m.role === "owner" ? "владелец" : "участник"} {isOwner && m.role !== "owner" && (
{error &&

{error}

}
); diff --git a/frontend/src/pages/MatchDetailPage.tsx b/frontend/src/pages/MatchDetailPage.tsx index d8ebc67..9858497 100644 --- a/frontend/src/pages/MatchDetailPage.tsx +++ b/frontend/src/pages/MatchDetailPage.tsx @@ -293,7 +293,6 @@ export function MatchDetailPage() { : "Сыгранная партия и её результаты будут удалены. Действие необратимо." } confirmLabel={inProgress ? "Отменить партию" : "Удалить"} - cancelLabel="Назад" onCancel={() => setConfirmRemove(false)} onConfirm={() => { setConfirmRemove(false); diff --git a/frontend/src/pages/admin/AdminAccountsPage.tsx b/frontend/src/pages/admin/AdminAccountsPage.tsx index 3876bda..e481452 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 : "Не удалось переименовать"); @@ -63,7 +63,7 @@ export function AdminAccountsPage() { disabled={update.isPending || !editName.trim() || editName.trim() === u.nickname} onClick={saveName} > - ОК + Сохранить
diff --git a/frontend/src/pages/admin/AdminLogsPage.tsx b/frontend/src/pages/admin/AdminLogsPage.tsx index 3569f01..8bb7a95 100644 --- a/frontend/src/pages/admin/AdminLogsPage.tsx +++ b/frontend/src/pages/admin/AdminLogsPage.tsx @@ -59,7 +59,7 @@ export function AdminLogsPage() { )}
))} - {(data?.items ?? []).length === 0 &&
Событий нет.
} + {(data?.items ?? []).length === 0 &&
Событий пока нет.
}
)}
diff --git a/frontend/src/pages/admin/AdminRecordsPage.tsx b/frontend/src/pages/admin/AdminRecordsPage.tsx index e4de2f4..6f762d4 100644 --- a/frontend/src/pages/admin/AdminRecordsPage.tsx +++ b/frontend/src/pages/admin/AdminRecordsPage.tsx @@ -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() { #{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}
@@ -120,7 +121,7 @@ export function AdminRecordsPage() { {isLoading ? ( ) : (matches ?? []).length === 0 ? ( -
Записей нет.
+
Записей пока нет.
) : grouped ? ( byGroup.map(([gid, { name, items }]) => (
From ca96073ccf059247e2c8eadde3bcb4ed26dc15a3 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 13 Jul 2026 17:45:35 +0300 Subject: [PATCH 06/15] =?UTF-8?q?UI:=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D1=8B=20=D0=BA=D0=BD=D0=BE=D0=BF?= =?UTF-8?q?=D0=BE=D0=BA=20=D0=B8=20=D0=B1=D0=B5=D0=B9=D0=B4=D0=B6=D0=B5?= =?UTF-8?q?=D0=B9=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D0=B4=D1=83?= =?UTF-8?q?=D0=B1=D0=BB=D0=B8=D1=80=D1=83=D1=8E=D1=89=D0=B8=D1=85=D1=81?= =?UTF-8?q?=D1=8F=20inline-=D1=81=D1=82=D0=B8=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .btn-block в global.css заменяет 9 повторов style={{width:100%}}; - .badge-live — единый бейдж «идёт» (3 копии inline-стиля); - незавершённые партии на главной используют стандартный .card, как список партий (была единственная карточка на самодельных стилях); - в списке партий добавлен бейдж 🎲 случайной фракции — как на странице партии (раньше в списке он терялся); - переключатель групп: кнопка «+» подписана «+ Создать группу» (по образцу «+ Добавить игрока»), убран пустой span. Co-Authored-By: Claude Fable 5 --- frontend/src/components/CreateGroupForm.tsx | 2 +- frontend/src/components/GroupSwitcher.tsx | 10 +++------- frontend/src/components/InProgressMatches.tsx | 15 +++------------ frontend/src/components/MatchList.tsx | 3 ++- frontend/src/pages/AccountPage.tsx | 2 +- frontend/src/pages/CreateMatchPage.tsx | 4 ++-- frontend/src/pages/MatchDetailPage.tsx | 11 +++-------- frontend/src/pages/PublicProfilePage.tsx | 6 ++---- frontend/src/pages/admin/AdminLoginPage.tsx | 2 +- frontend/src/pages/admin/AdminMatchEdit.tsx | 2 +- frontend/src/styles/global.css | 3 +++ 11 files changed, 22 insertions(+), 38 deletions(-) diff --git a/frontend/src/components/CreateGroupForm.tsx b/frontend/src/components/CreateGroupForm.tsx index 3c8a524..5037bc9 100644 --- a/frontend/src/components/CreateGroupForm.tsx +++ b/frontend/src/components/CreateGroupForm.tsx @@ -58,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..7a7f88c 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 750232e..23b9861 100644 --- a/frontend/src/components/InProgressMatches.tsx +++ b/frontend/src/components/InProgressMatches.tsx @@ -18,23 +18,14 @@ export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) { // а интерактивные элементы внутри diff --git a/frontend/src/pages/CreateMatchPage.tsx b/frontend/src/pages/CreateMatchPage.tsx index 0d443ae..01f0aa9 100644 --- a/frontend/src/pages/CreateMatchPage.tsx +++ b/frontend/src/pages/CreateMatchPage.tsx @@ -236,8 +236,8 @@ export function CreateMatchPage() { {error &&

{error}

} )} 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 ? (
{error &&

{error}

} - diff --git a/frontend/src/pages/admin/AdminMatchEdit.tsx b/frontend/src/pages/admin/AdminMatchEdit.tsx index 7e81778..3a3b96f 100644 --- a/frontend/src/pages/admin/AdminMatchEdit.tsx +++ b/frontend/src/pages/admin/AdminMatchEdit.tsx @@ -186,7 +186,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: {error &&

{error}

} - diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 9937cb1..809e553 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; } From 28852f9d38556af9f9b93583f80ee1b03d5e659a Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 13 Jul 2026 17:46:23 +0300 Subject: [PATCH 07/15] =?UTF-8?q?UI:=20=D0=B7=D0=B0=D0=B3=D0=BE=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B8=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=86=20=D0=B1=D0=B5=D0=B7=20=D0=B4=D1=83=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=85=D0=BD=D0=B5=D0=B9=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D0=B8?= =?UTF-8?q?=20=D0=B8=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Единое правило: название страницы показывает верхняя панель (или активная вкладка админки), тело страницы его не повторяет. Убраны дубли на общей статистике, в справке и в админ-разделах (аккаунты, фракции, журнал); пояснительные абзацы сохранены. Заголовки-сущности (имя группы, дата партии) остаются h2 в теле. Co-Authored-By: Claude Fable 5 --- frontend/src/pages/HelpPage.tsx | 4 ++-- frontend/src/pages/OverallStatsPage.tsx | 2 +- frontend/src/pages/admin/AdminAccountsPage.tsx | 2 +- frontend/src/pages/admin/AdminFactionsPage.tsx | 4 ++-- frontend/src/pages/admin/AdminLogsPage.tsx | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/pages/HelpPage.tsx b/frontend/src/pages/HelpPage.tsx index 3a29a24..450aecc 100644 --- a/frontend/src/pages/HelpPage.tsx +++ b/frontend/src/pages/HelpPage.tsx @@ -5,9 +5,9 @@ export function HelpPage() { return (
+ {/* Название страницы уже в верхней панели — в теле только пояснение. */}
-

Справка

-

+

Здесь со временем появятся правила, подсказки и пояснения. Пока — о том, как считается рейтинг игроков.

diff --git a/frontend/src/pages/OverallStatsPage.tsx b/frontend/src/pages/OverallStatsPage.tsx index c7bc1b1..36f3ac5 100644 --- a/frontend/src/pages/OverallStatsPage.tsx +++ b/frontend/src/pages/OverallStatsPage.tsx @@ -58,8 +58,8 @@ export function OverallStatsPage() { return (
+ {/* Название страницы уже в верхней панели — в теле сразу поиск. */}
-

Общая статистика

setQ(e.target.value)} />
diff --git a/frontend/src/pages/admin/AdminAccountsPage.tsx b/frontend/src/pages/admin/AdminAccountsPage.tsx index e481452..1746e8f 100644 --- a/frontend/src/pages/admin/AdminAccountsPage.tsx +++ b/frontend/src/pages/admin/AdminAccountsPage.tsx @@ -42,7 +42,7 @@ export function AdminAccountsPage() { return (
-

Аккаунты

+ {/* Название раздела уже в активной вкладке админки. */}
{(users ?? []).map((u) => (
diff --git a/frontend/src/pages/admin/AdminFactionsPage.tsx b/frontend/src/pages/admin/AdminFactionsPage.tsx index 87ff035..dc955e7 100644 --- a/frontend/src/pages/admin/AdminFactionsPage.tsx +++ b/frontend/src/pages/admin/AdminFactionsPage.tsx @@ -30,8 +30,8 @@ export function AdminFactionsPage() { return (
-

Фракции

-

+ {/* Название раздела уже в активной вкладке админки. */} +

Переименование меняет название фракции во всех партиях и статистике.

diff --git a/frontend/src/pages/admin/AdminLogsPage.tsx b/frontend/src/pages/admin/AdminLogsPage.tsx index 8bb7a95..4f14852 100644 --- a/frontend/src/pages/admin/AdminLogsPage.tsx +++ b/frontend/src/pages/admin/AdminLogsPage.tsx @@ -17,7 +17,7 @@ export function AdminLogsPage() { return (
-

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

+ {/* Название раздела уже в активной вкладке админки. */}
setNewNick(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") invite(); + }} + /> + +
+ )}
diff --git a/frontend/src/pages/GroupSettingsPage.tsx b/frontend/src/pages/GroupSettingsPage.tsx index be2b233..f4d7f67 100644 --- a/frontend/src/pages/GroupSettingsPage.tsx +++ b/frontend/src/pages/GroupSettingsPage.tsx @@ -1,4 +1,4 @@ -import { Trash2, UserPlus } from "lucide-react"; +import { Trash2 } from "lucide-react"; import { useEffect, useState } from "react"; import { ApiError } from "../api/client"; @@ -9,7 +9,7 @@ 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, useSetGroupExpansions } from "../hooks/groups"; import { useExpansions } from "../hooks/reference"; export function GroupSettingsPage() { @@ -19,12 +19,10 @@ export function GroupSettingsPage() { const { data: expansions } = useExpansions(); const { data: members } = useGroupMembers(groupId); const setExpansions = useSetGroupExpansions(groupId ?? 0); - const inviteMember = useInviteMember(); const removeMember = useRemoveMember(groupId ?? 0); const toast = useToast(); const [selected, setSelected] = useState>(new Set()); - const [newNick, setNewNick] = useState(""); const [removeTarget, setRemoveTarget] = useState<{ userId: number; nickname: string } | null>(null); useEffect(() => { @@ -51,18 +49,6 @@ export function GroupSettingsPage() { } }; - const add = async () => { - const nick = newNick.trim(); - if (!nick) return; - try { - await inviteMember.mutateAsync({ groupId: group.id, nickname: nick }); - setNewNick(""); - toast.show("Приглашение отправлено"); - } catch (e) { - toast.show(e instanceof ApiError ? e.message : "Ошибка"); - } - }; - const remove = async (userId: number) => { try { await removeMember.mutateAsync(userId); @@ -99,22 +85,8 @@ export function GroupSettingsPage() {
+ {/* Приглашение нового игрока — на странице группы («Список игроков»). */}

Участники ({(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) => (
From c4f75d5de095dda8ffc202a0e06165949678f85d Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 15 Jul 2026 17:19:07 +0300 Subject: [PATCH 10/15] =?UTF-8?q?=D0=9D=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D0=BA=D0=B8=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B:=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B8=D0=BC=D0=B5=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый хук useRenameGroup (PATCH /api/groups/{id} — эндпойнт уже был на бэкенде) с инвалидацией всех мест, где видно имя группы (шапка группы, переключатель групп, главная). В настройках — карточка «Название» с полем и кнопкой «Сохранить» (по образцу смены ника в аккаунте). Co-Authored-By: Claude Fable 5 --- frontend/src/hooks/groups.ts | 20 +++++++++++ frontend/src/pages/GroupSettingsPage.tsx | 45 +++++++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) 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/pages/GroupSettingsPage.tsx b/frontend/src/pages/GroupSettingsPage.tsx index f4d7f67..2166844 100644 --- a/frontend/src/pages/GroupSettingsPage.tsx +++ b/frontend/src/pages/GroupSettingsPage.tsx @@ -9,7 +9,13 @@ 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, useRemoveMember, useSetGroupExpansions } from "../hooks/groups"; +import { + useGroup, + useGroupMembers, + useRemoveMember, + useRenameGroup, + useSetGroupExpansions, +} from "../hooks/groups"; import { useExpansions } from "../hooks/reference"; export function GroupSettingsPage() { @@ -19,10 +25,13 @@ export function GroupSettingsPage() { const { data: expansions } = useExpansions(); const { data: members } = useGroupMembers(groupId); const setExpansions = useSetGroupExpansions(groupId ?? 0); + const renameGroup = useRenameGroup(groupId ?? 0); const removeMember = useRemoveMember(groupId ?? 0); const toast = useToast(); const [selected, setSelected] = useState>(new Set()); + // Название редактируется локально; до первого ввода берём значение из группы. + const [name, setName] = useState(null); const [removeTarget, setRemoveTarget] = useState<{ userId: number; nickname: string } | null>(null); useEffect(() => { @@ -40,6 +49,20 @@ export function GroupSettingsPage() { return next; }); + const nameValue = name ?? group.name; + + const saveName = async () => { + const trimmed = nameValue.trim(); + if (!trimmed || trimmed === group.name) return; + try { + await renameGroup.mutateAsync(trimmed); + setName(null); + toast.show("Название сохранено"); + } catch (e) { + toast.show(e instanceof ApiError ? e.message : "Ошибка"); + } + }; + const saveExpansions = async () => { try { await setExpansions.mutateAsync([...selected]); @@ -59,6 +82,26 @@ export function GroupSettingsPage() { return (
+
+

Название

+
+ setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") saveName(); + }} + /> + +
+
+

Дополнения

From ac296a8f12aae7d6e3e741a647b204f53fe85add Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 15 Jul 2026 17:21:20 +0300 Subject: [PATCH 11/15] =?UTF-8?q?=D0=9D=D0=BE=D0=B2=D0=B8=D1=87=D0=BA?= =?UTF-8?q?=D0=B8:=20=D1=81=D0=B5=D1=80=D0=B5=D0=B1=D1=80=D1=8F=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D1=80=D0=B5=D0=B9=D1=82=D0=B8=D0=BD=D0=B3=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D1=80=D0=BE=D0=B3=205=20=D0=B8=D0=B3?= =?UTF-8?q?=D1=80=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - рейтинг в блоках «Новички» (топ игроков и общая статистика) выводится серебряным (--silver, класс .lb-score.provisional), а не золотым; - MIN_GAMES 3 -> 5 (scoring.py) — в ранжированный топ попадают с 5 игр; подпись «Новички (меньше N игр)» берёт порог из API, справка обновлена; - pytest: 57 passed. Co-Authored-By: Claude Fable 5 --- backend/app/services/scoring.py | 2 +- frontend/src/components/Leaderboard.tsx | 6 +++--- frontend/src/pages/HelpPage.tsx | 2 +- frontend/src/pages/OverallStatsPage.tsx | 10 ++++++---- frontend/src/styles/layout.css | 2 ++ frontend/src/styles/tokens.css | 1 + 6 files changed, 14 insertions(+), 9 deletions(-) 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/frontend/src/components/Leaderboard.tsx b/frontend/src/components/Leaderboard.tsx index 508fe01..a7c263a 100644 --- a/frontend/src/components/Leaderboard.tsx +++ b/frontend/src/components/Leaderboard.tsx @@ -6,7 +6,7 @@ function pct(v: number | null | undefined): string { return v == null ? "—" : `${Math.round(v * 100)}%`; } -function Row({ entry }: { entry: LeaderboardEntry }) { +function Row({ entry, provisional }: { entry: LeaderboardEntry; provisional?: boolean }) { return ( // Вся строка — ссылка на профиль (на главной карточка-обёртка ведёт в общую // статистику, а клик по строке игрока — в его профиль). @@ -23,7 +23,7 @@ function Row({ entry }: { entry: LeaderboardEntry }) { WR: {pct(entry.win_rate)}
-
{entry.score ?? "—"}
+
{entry.score ?? "—"}
); } @@ -53,7 +53,7 @@ export function Leaderboard({ Новички (мало игр)
{provisional.map((e) => ( - + ))} )} diff --git a/frontend/src/pages/HelpPage.tsx b/frontend/src/pages/HelpPage.tsx index 450aecc..10fbf77 100644 --- a/frontend/src/pages/HelpPage.tsx +++ b/frontend/src/pages/HelpPage.tsx @@ -55,7 +55,7 @@ export function HelpPage() {

Попадание в топ

- Чтобы попасть в ранжированный топ, нужно сыграть минимум 3 партии. До этого + Чтобы попасть в ранжированный топ, нужно сыграть минимум 5 партий. До этого игрок показывается в блоке «Новички» — предварительно, без места в рейтинге.

diff --git a/frontend/src/pages/OverallStatsPage.tsx b/frontend/src/pages/OverallStatsPage.tsx index 36f3ac5..74d42d2 100644 --- a/frontend/src/pages/OverallStatsPage.tsx +++ b/frontend/src/pages/OverallStatsPage.tsx @@ -42,7 +42,7 @@ 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,7 +52,9 @@ export function OverallStatsPage() { {e.games} {e.wins} {pct(e.win_rate)} - {e.score ?? "—"} + + {e.score ?? "—"} + ); @@ -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/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; From dbc32b7f4d612564aa9032551e7316c351c757d5 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 15 Jul 2026 17:24:31 +0300 Subject: [PATCH 12/15] =?UTF-8?q?UI:=20=D0=B2=20=D0=BD=D0=B5=D0=B7=D0=B0?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D1=88=D1=91=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BF?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=B8=D1=8F=D1=85=20=D0=BD=D0=B0=20=D0=B3?= =?UTF-8?q?=D0=BB=D0=B0=D0=B2=D0=BD=D0=BE=D0=B9=20=D0=BD=D0=B8=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=BD=D0=B5=D0=BA=D0=BB=D0=B8=D0=BA=D0=B0=D0=B1=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B;=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20?= =?UTF-8?q?=C2=AB=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D1=82=D1=8C=20=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=BF=D0=BF=D1=83=C2=BB=20=D0=B1=D0=B5=D0=B7=20=D0=BF?= =?UTF-8?q?=D0=BB=D1=8E=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Как и в списке партий группы: карточка целиком ведёт в партию, ссылки на профили внутри мешали выбору. Элемент возвращён к
diff --git a/frontend/src/components/InProgressMatches.tsx b/frontend/src/components/InProgressMatches.tsx index 23b9861..d4528c7 100644 --- a/frontend/src/components/InProgressMatches.tsx +++ b/frontend/src/components/InProgressMatches.tsx @@ -3,7 +3,6 @@ import { useNavigate } from "react-router-dom"; import { formatTime, plural } from "../domain/format"; import type { HomeInProgressMatch } from "../domain/types"; -import { PlayerLink } from "./PlayerLink"; export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) { const navigate = useNavigate(); @@ -14,13 +13,11 @@ export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) {

Незавершённые партии

{items.map((m) => ( - // div, а не button: внутри лежат ссылки на профили игроков, - // а интерактивные элементы внутри ))}
From fc1b1fd9123468e1d4c1cfcf3e5aa7e77698be57 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 15 Jul 2026 17:28:21 +0300 Subject: [PATCH 13/15] =?UTF-8?q?UI:=20=D0=B2=20=D1=82=D0=BE=D0=BF=D0=B5?= =?UTF-8?q?=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=BE=D0=B2=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=B3=D0=BB=D0=B0=D0=B2=D0=BD=D0=BE=D0=B9=20=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BD=D0=B5=D0=BA=D0=BB=D0=B8=D0=BA=D0=B0=D0=B1=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Карточка топа целиком ведёт в общую статистику — вложенные ссылки на профили конфликтовали с этим переходом. У Leaderboard появился флаг profileLinks: на главной выключен, в «Списке игроков» группы (карточка никуда не ведёт) строки-ссылки остаются, как и в общей статистике. Co-Authored-By: Claude Fable 5 --- frontend/src/components/Leaderboard.tsx | 32 ++++++++++++++++++------- frontend/src/pages/HomePage.tsx | 6 ++--- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/Leaderboard.tsx b/frontend/src/components/Leaderboard.tsx index a7c263a..7e513b1 100644 --- a/frontend/src/components/Leaderboard.tsx +++ b/frontend/src/components/Leaderboard.tsx @@ -6,11 +6,17 @@ function pct(v: number | null | undefined): string { return v == null ? "—" : `${Math.round(v * 100)}%`; } -function Row({ entry, provisional }: { entry: LeaderboardEntry; provisional?: boolean }) { - return ( - // Вся строка — ссылка на профиль (на главной карточка-обёртка ведёт в общую - // статистику, а клик по строке игрока — в его профиль). - +function Row({ + entry, + provisional, + profileLinks, +}: { + entry: LeaderboardEntry; + provisional?: boolean; + profileLinks: boolean; +}) { + const content = ( + <>
{entry.rank ?? "—"}
@@ -24,6 +30,14 @@ function Row({ entry, provisional }: { entry: LeaderboardEntry; provisional?: bo
{entry.score ?? "—"}
+ + ); + // Строка-ссылка на профиль — только там, где сама карточка никуда не ведёт + // (на главной карточка топа целиком открывает общую статистику). + if (!profileLinks) return
{content}
; + return ( + + {content} ); } @@ -32,10 +46,12 @@ 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; @@ -45,7 +61,7 @@ export function Leaderboard({ return (
{entries.map((e) => ( - + ))} {provisional && provisional.length > 0 && ( <> @@ -53,7 +69,7 @@ export function Leaderboard({ Новички (мало игр)
{provisional.map((e) => ( - + ))} )} @@ -63,7 +79,7 @@ export function Leaderboard({ Ещё не играли
{inactive.map((e) => ( - + ))} )} 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")}>

Топ игроков

Вся статистика →
- +
From e5b7420bae6c41c002f628b9c5490d32427d7c16 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Fri, 17 Jul 2026 02:22:27 +0300 Subject: [PATCH 14/15] =?UTF-8?q?API:=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20?= =?UTF-8?q?=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=BE=D0=B2=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=D1=83=20=D0=B4=D0=BB=D1=8F=20=D0=B0=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D0=BA=D0=BE=D0=BC=D0=BF=D0=BB=D0=B8=D1=82=D0=B0=20?= =?UTF-8?q?(GET=20/api/users/search)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit До 10 подсказок по подстроке ника среди активных игроков (role=player). Регистронезависимо и для кириллицы: SQLite LIKE/lower сворачивают регистр только у ASCII, поэтому фильтрация/ранжирование в Python (casefold): префиксные совпадения раньше подстрочных, внутри — по алфавиту. Пустой q — пустой список. Тесты + регенерированный schema.d.ts. Co-Authored-By: Claude Fable 5 --- backend/app/routers/users.py | 19 ++++++++ backend/app/schemas/api.py | 8 ++++ backend/app/services/user_service.py | 22 ++++++++++ backend/tests/test_user_search.py | 65 ++++++++++++++++++++++++++++ frontend/src/api/schema.d.ts | 64 +++++++++++++++++++++++++++ 5 files changed, 178 insertions(+) create mode 100644 backend/tests/test_user_search.py 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/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/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; From ddbcd74058f672383b6bbaac4c9564e172c95ab3 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Fri, 17 Jul 2026 02:30:55 +0300 Subject: [PATCH 15/15] =?UTF-8?q?UI:=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=D0=BA=D0=B8=20=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D0=BF=D1=80=D0=B8=D0=B3=D0=BB=D0=B0=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B2=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При вводе в поле приглашения («Список игроков») под инпутом всплывают до 3 подходящих игроков — аватар + ник, в стилистике строк топов. Клик подставляет ник в поле (приглашение отправляется кнопкой/Enter), уже состоящие в группе не предлагаются. Детали: компонент PlayerSuggestions (выбор по mousedown — до blur инпута), хук useUserSearch с дебаунсом 250 мс и keepPreviousData, классы .suggest-* в духе .notif-panel/.lb-row. В поиске общего списка подсказки не нужны — таблица уже фильтруется вживую при вводе. Co-Authored-By: Claude Fable 5 --- frontend/src/api/queryKeys.ts | 1 + frontend/src/components/PlayerSuggestions.tsx | 44 +++++++++++++++++ frontend/src/hooks/users.ts | 30 ++++++++++++ frontend/src/pages/GroupPage.tsx | 47 +++++++++++++++---- frontend/src/styles/global.css | 33 +++++++++++++ 5 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/PlayerSuggestions.tsx create mode 100644 frontend/src/hooks/users.ts 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/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) => ( + + ))} +
+ ); +} 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/GroupPage.tsx b/frontend/src/pages/GroupPage.tsx index 9ded3a4..d2bce5c 100644 --- a/frontend/src/pages/GroupPage.tsx +++ b/frontend/src/pages/GroupPage.tsx @@ -8,6 +8,7 @@ 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"; @@ -20,6 +21,7 @@ import { useGroupStats, useInviteMember, } from "../hooks/groups"; +import { useUserSearch } from "../hooks/users"; export function GroupPage() { const { data: me, isLoading } = useMe(); @@ -33,6 +35,12 @@ export function GroupPage() { 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 ; @@ -48,6 +56,12 @@ export function GroupPage() { } }; + // Подсказки: уже состоящих в группе не предлагаем, показываем до 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 (
@@ -99,14 +113,31 @@ export function GroupPage() {

) : (
- setNewNick(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") invite(); - }} - /> +
+ { + setNewNick(e.target.value); + setNickPicked(false); + }} + onFocus={() => setNickFocused(true)} + onBlur={() => setNickFocused(false)} + onKeyDown={(e) => { + if (e.key === "Enter") invite(); + if (e.key === "Escape") e.currentTarget.blur(); + }} + /> + {showSuggest && ( + { + setNewNick(u.nickname); + setNickPicked(true); + }} + /> + )} +
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 809e553..096faf8 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -289,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;