From 93d81010f7ba29bd7748eaca739f60e463b4ee6f Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sat, 18 Jul 2026 03:23:14 +0300 Subject: [PATCH] =?UTF-8?q?UI:=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D1=84?= =?UTF-8?q?=D1=80=D0=B0=D0=BA=D1=86=D0=B8=D0=B8=20=E2=80=94=20=D0=B2=D1=8B?= =?UTF-8?q?=D0=BF=D0=B0=D0=B4=D0=B0=D1=88=D0=BA=D0=B0=20=D0=B2=20=D1=81?= =?UTF-8?q?=D1=82=D0=B8=D0=BB=D0=B5=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=B0?= =?UTF-8?q?=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=B0,=20=D0=B1=D0=B5=D0=B7?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BD=D1=8F=D1=82=D1=8B=D1=85=20=D1=84=D1=80?= =?UTF-8?q?=D0=B0=D0=BA=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlayerSelect обобщён в PickerSelect (рендер опций задаёт родитель) — им оформлены оба поля создания партии. Фракции, выбранные в других строках, не предлагаются (сервер при рандоме и так их исключал); набор фракций по-прежнему приходит из available_factions группы с учётом дополнений. Выбранное значение ищется в полном списке, а не в отфильтрованном: после «Рандом всем» с повторами фракция строки может быть занята соседней. Co-Authored-By: Claude Fable 5 --- frontend/src/components/PickerSelect.tsx | 69 ++++++++++++++++++++++++ frontend/src/components/PlayerSelect.tsx | 55 ------------------- frontend/src/pages/CreateMatchPage.tsx | 62 +++++++++++---------- frontend/src/styles/global.css | 2 +- 4 files changed, 100 insertions(+), 88 deletions(-) create mode 100644 frontend/src/components/PickerSelect.tsx delete mode 100644 frontend/src/components/PlayerSelect.tsx diff --git a/frontend/src/components/PickerSelect.tsx b/frontend/src/components/PickerSelect.tsx new file mode 100644 index 0000000..a042c00 --- /dev/null +++ b/frontend/src/components/PickerSelect.tsx @@ -0,0 +1,69 @@ +import { ChevronDown } from "lucide-react"; +import { type ReactNode, useState } from "react"; + +/** + * Выпадающий выбор «как select» в стиле подсказок ников (.suggest-*): + * кнопка-триггер с выбранным значением, под ней список опций. Родитель сам + * фильтрует options (например, убирает выбранное в других строках) и передаёт + * selected, найденный в ПОЛНОМ списке: выбранное значение может отсутствовать + * в options (например, фракция занята другой строкой после «Рандом всем»). + */ +export function PickerSelect({ + selected, + options, + placeholder, + renderOption, + renderValue, + onPick, +}: { + selected: T | null; + options: T[]; + placeholder: string; + renderOption: (option: T) => ReactNode; + /** Отрисовка выбранного в триггере; по умолчанию — как renderOption. */ + renderValue?: (option: T) => ReactNode; + onPick: (option: T) => void; +}) { + const [open, setOpen] = useState(false); + return ( +
+ + {open && options.length > 0 && ( +
+ {options.map((o) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/PlayerSelect.tsx b/frontend/src/components/PlayerSelect.tsx deleted file mode 100644 index 8c9612e..0000000 --- a/frontend/src/components/PlayerSelect.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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 ( -
- - {open && ( - { - onPick(u.user_id); - setOpen(false); - }} - /> - )} -
- ); -} diff --git a/frontend/src/pages/CreateMatchPage.tsx b/frontend/src/pages/CreateMatchPage.tsx index 128b962..e6aa1e9 100644 --- a/frontend/src/pages/CreateMatchPage.tsx +++ b/frontend/src/pages/CreateMatchPage.tsx @@ -3,7 +3,8 @@ import { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { ApiError } from "../api/client"; -import { PlayerSelect } from "../components/PlayerSelect"; +import { Avatar } from "../components/Avatar"; +import { PickerSelect } from "../components/PickerSelect"; import { Spinner } from "../components/Spinner"; import { MAX_MATCH_PLAYERS } from "../domain/constants"; import { useToast } from "../context/ToastContext"; @@ -168,6 +169,20 @@ export function CreateMatchPage() { const memberOptions = (members ?? []) as MemberRead[]; const spinning = Object.values(spin).some((v) => v != null); + // Опции выпадашек. Игрок — аватар + ник (в триггере аватар мельче, чтобы + // поле не разъезжалось по высоте с соседними). + const playerOptions = memberOptions.map((m) => ({ + id: m.user_id, + nickname: m.nickname, + avatar_url: m.avatar_url, + })); + const playerLabel = (m: { nickname: string; avatar_url?: string | null }, size: number) => ( + <> + + {m.nickname} + + ); + return (
@@ -189,43 +204,26 @@ export function CreateMatchPage() { )}
- !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 })} + m.id === row.user_id) ?? null} + options={playerOptions.filter((m) => !takenUserIds(idx).includes(m.id))} + placeholder="— игрок —" + renderOption={(m) => playerLabel(m, 28)} + renderValue={(m) => playerLabel(m, 24)} + onPick={(m) => update(idx, { user_id: m.id })} />
{spin[idx] != null ? (
{spin[idx]}
) : ( - + f.id === row.faction_id) ?? null} + options={(factions ?? []).filter((f) => !takenFactionIds(idx).includes(f.id))} + placeholder="— фракция —" + renderOption={(f) => f.name_ru} + onPick={(f) => update(idx, { faction_id: f.id, was_random: false })} + /> )}