UI: подсказки ников при приглашении игрока в группу
При вводе в поле приглашения («Список игроков») под инпутом всплывают до 3 подходящих игроков — аватар + ник, в стилистике строк топов. Клик подставляет ник в поле (приглашение отправляется кнопкой/Enter), уже состоящие в группе не предлагаются. Детали: компонент PlayerSuggestions (выбор по mousedown — до blur инпута), хук useUserSearch с дебаунсом 250 мс и keepPreviousData, классы .suggest-* в духе .notif-panel/.lb-row. В поиске общего списка подсказки не нужны — таблица уже фильтруется вживую при вводе. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<div className="suggest-list" role="listbox">
|
||||
{items.map((u) => (
|
||||
<button
|
||||
key={u.user_id}
|
||||
type="button"
|
||||
className="suggest-row"
|
||||
role="option"
|
||||
// mousedown, а не click: mousedown срабатывает ДО blur инпута — иначе
|
||||
// onBlur родителя скрыл бы список раньше клика. preventDefault
|
||||
// оставляет фокус в инпуте.
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onPick(u);
|
||||
}}
|
||||
>
|
||||
<Avatar url={u.avatar_url} nickname={u.nickname} size={28} />
|
||||
<span className="nick">{u.nickname}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<T>(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 } } })),
|
||||
});
|
||||
}
|
||||
@@ -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 <Spinner />;
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
@@ -99,14 +113,31 @@ export function GroupPage() {
|
||||
</p>
|
||||
) : (
|
||||
<div className="row" style={{ marginTop: 10 }}>
|
||||
<input
|
||||
placeholder="Ник игрока — отправить приглашение"
|
||||
value={newNick}
|
||||
onChange={(e) => setNewNick(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") invite();
|
||||
}}
|
||||
/>
|
||||
<div className="suggest-wrap">
|
||||
<input
|
||||
placeholder="Ник игрока — отправить приглашение"
|
||||
value={newNick}
|
||||
onChange={(e) => {
|
||||
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 && (
|
||||
<PlayerSuggestions
|
||||
items={suggestions}
|
||||
onPick={(u) => {
|
||||
setNewNick(u.nickname);
|
||||
setNickPicked(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={invite} disabled={inviteMember.isPending}>
|
||||
<UserPlus size={18} />
|
||||
</button>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user