UI: выбор игрока при создании партии — выпадашка в стиле подсказок

Вместо нативного select — кнопка-триггер (аватар + ник) с выпадающим
списком всех игроков группы в стилистике подсказок инвайта; игроки,
уже выбранные в других строках, не предлагаются (заодно исключены
дубли). Для аватаров в списке MemberRead теперь отдаёт avatar_url.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 03:00:26 +03:00
co-authored by Claude Fable 5
parent 4a681430af
commit f22ee1ec29
7 changed files with 137 additions and 16 deletions
+2
View File
@@ -1778,6 +1778,8 @@ export interface components {
nickname: string;
/** Role */
role: string;
/** Avatar Url */
avatar_url?: string | null;
};
/** MemberRoleUpdate */
MemberRoleUpdate: {
+63
View File
@@ -0,0 +1,63 @@
import { ChevronDown } from "lucide-react";
import { useState } from "react";
import { Avatar } from "./Avatar";
import { PlayerSuggestions, type PlayerSuggestion } from "./PlayerSuggestions";
/**
* Выбор игрока «как select», но в стиле выпадающих подсказок ников:
* кнопка-триггер с аватаром и ником, под ней список options. Родитель сам
* фильтрует options (например, убирает выбранных в других строках).
*/
export function PlayerSelect({
value,
options,
onPick,
}: {
value: number | "";
options: PlayerSuggestion[];
onPick: (userId: number | "") => void;
}) {
const [open, setOpen] = useState(false);
const selected = value === "" ? null : options.find((o) => o.user_id === value) ?? null;
return (
<div className="suggest-wrap">
<button
type="button"
className="picker-trigger"
onClick={() => setOpen((v) => !v)}
onBlur={() => setOpen(false)}
onKeyDown={(e) => {
if (e.key === "Escape") setOpen(false);
}}
>
{selected ? (
<>
<Avatar url={selected.avatar_url} nickname={selected.nickname} size={24} />
<span className="nick">{selected.nickname}</span>
</>
) : (
<span className="picker-placeholder">— игрок —</span>
)}
<ChevronDown size={16} className="picker-chevron" />
</button>
{open && (
<PlayerSuggestions
items={options}
onPick={(u) => {
onPick(u.user_id);
setOpen(false);
}}
onClear={
selected
? () => {
onPick("");
setOpen(false);
}
: undefined
}
/>
)}
</div>
);
}
+16 -1
View File
@@ -14,13 +14,28 @@ export interface PlayerSuggestion {
export function PlayerSuggestions({
items,
onPick,
onClear,
}: {
items: PlayerSuggestion[];
onPick: (item: PlayerSuggestion) => void;
// Необязательная строка сброса сверху списка («— игрок —» в PlayerSelect).
onClear?: () => void;
}) {
if (items.length === 0) return null;
if (items.length === 0 && !onClear) return null;
return (
<div className="suggest-list" role="listbox">
{onClear && (
<button
type="button"
className="suggest-row suggest-clear"
onMouseDown={(e) => {
e.preventDefault();
onClear();
}}
>
— игрок —
</button>
)}
{items.map((u) => (
<button
key={u.user_id}
+18 -10
View File
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ApiError } from "../api/client";
import { PlayerSelect } from "../components/PlayerSelect";
import { Spinner } from "../components/Spinner";
import { MAX_MATCH_PLAYERS } from "../domain/constants";
import { useToast } from "../context/ToastContext";
@@ -55,6 +56,13 @@ export function CreateMatchPage() {
.map((r) => r.faction_id)
.filter((x): x is number => typeof x === "number");
// Игроки, уже выбранные в других строках, — в списке не предлагаются.
const takenUserIds = (exceptIdx: number) =>
rows
.filter((_, i) => i !== exceptIdx)
.map((r) => r.user_id)
.filter((x): x is number => typeof x === "number");
const update = (idx: number, patch: Partial<Draft>) =>
setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
@@ -181,17 +189,17 @@ export function CreateMatchPage() {
)}
</div>
<select
<PlayerSelect
value={row.user_id}
onChange={(e) => update(idx, { user_id: e.target.value ? Number(e.target.value) : "" })}
>
<option value="">— игрок —</option>
{memberOptions.map((m) => (
<option key={m.user_id} value={m.user_id}>
{m.nickname}
</option>
))}
</select>
options={memberOptions
.filter((m) => !takenUserIds(idx).includes(m.user_id))
.map((m) => ({
user_id: m.user_id,
nickname: m.nickname,
avatar_url: m.avatar_url,
}))}
onPick={(id) => update(idx, { user_id: id })}
/>
<div className="row">
{spin[idx] != null ? (
+23
View File
@@ -321,6 +321,29 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
.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; }
.suggest-clear { color: var(--muted); font-weight: 400; }
/* Кнопка-триггер PlayerSelect: выглядит как select, раскрывает .suggest-list. */
.picker-trigger {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-height: 42px; /* высота обычного input/select — строки формы не «прыгают» */
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
font: inherit;
text-align: left;
cursor: pointer;
outline: none;
}
.picker-trigger:focus { border-color: var(--accent); }
.picker-trigger .nick { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.picker-placeholder { color: var(--muted); }
.picker-chevron { margin-left: auto; flex: none; color: var(--muted); }
/* Всплывашки сверху экрана в момент прихода */
.toast-top-wrap {