From ddbcd74058f672383b6bbaac4c9564e172c95ab3 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Fri, 17 Jul 2026 02:30:55 +0300 Subject: [PATCH] =?UTF-8?q?UI:=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D0=BA=D0=B8=20=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D0=BF=D1=80=D0=B8=D0=B3=D0=BB=D0=B0=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B8=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=B0=20?= =?UTF-8?q?=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;