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();
- }}
- />
+
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;