UI: выбор игрока при создании партии — выпадашка в стиле подсказок
Вместо нативного select — кнопка-триггер (аватар + ник) с выпадающим списком всех игроков группы в стилистике подсказок инвайта; игроки, уже выбранные в других строках, не предлагаются (заодно исключены дубли). Для аватаров в списке MemberRead теперь отдаёт avatar_url. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ from app.services import (
|
|||||||
notification_service,
|
notification_service,
|
||||||
notify,
|
notify,
|
||||||
stats_service,
|
stats_service,
|
||||||
|
user_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/groups", tags=["groups"])
|
router = APIRouter(prefix="/groups", tags=["groups"])
|
||||||
@@ -127,7 +128,12 @@ def list_members(
|
|||||||
) -> list[s.MemberRead]:
|
) -> list[s.MemberRead]:
|
||||||
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
|
||||||
return [
|
return [
|
||||||
s.MemberRead(user_id=u.id, nickname=u.nickname, role=m.role)
|
s.MemberRead(
|
||||||
|
user_id=u.id,
|
||||||
|
nickname=u.nickname,
|
||||||
|
role=m.role,
|
||||||
|
avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at),
|
||||||
|
)
|
||||||
for m, u in membership_service.list_members(session, group_id)
|
for m, u in membership_service.list_members(session, group_id)
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -195,10 +201,13 @@ def change_member_role(
|
|||||||
group_service.assert_owner(session, group_id, user.id) # type: ignore[arg-type]
|
group_service.assert_owner(session, group_id, user.id) # type: ignore[arg-type]
|
||||||
group = group_service.get_group(session, group_id)
|
group = group_service.get_group(session, group_id)
|
||||||
member = membership_service.change_role(session, group, user_id, body.role)
|
member = membership_service.change_role(session, group, user_id, body.role)
|
||||||
from app.services.user_service import get_user
|
u = user_service.get_user(session, user_id)
|
||||||
|
return s.MemberRead(
|
||||||
u = get_user(session, user_id)
|
user_id=u.id,
|
||||||
return s.MemberRead(user_id=u.id, nickname=u.nickname, role=member.role)
|
nickname=u.nickname,
|
||||||
|
role=member.role,
|
||||||
|
avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ─── Партии и статистика группы ──────────────────────────────────────────────
|
# ─── Партии и статистика группы ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ class MemberRead(BaseModel):
|
|||||||
user_id: int
|
user_id: int
|
||||||
nickname: str
|
nickname: str
|
||||||
role: str
|
role: str
|
||||||
|
avatar_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class MemberAdd(BaseModel):
|
class MemberAdd(BaseModel):
|
||||||
|
|||||||
Vendored
+2
@@ -1778,6 +1778,8 @@ export interface components {
|
|||||||
nickname: string;
|
nickname: string;
|
||||||
/** Role */
|
/** Role */
|
||||||
role: string;
|
role: string;
|
||||||
|
/** Avatar Url */
|
||||||
|
avatar_url?: string | null;
|
||||||
};
|
};
|
||||||
/** MemberRoleUpdate */
|
/** MemberRoleUpdate */
|
||||||
MemberRoleUpdate: {
|
MemberRoleUpdate: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,13 +14,28 @@ export interface PlayerSuggestion {
|
|||||||
export function PlayerSuggestions({
|
export function PlayerSuggestions({
|
||||||
items,
|
items,
|
||||||
onPick,
|
onPick,
|
||||||
|
onClear,
|
||||||
}: {
|
}: {
|
||||||
items: PlayerSuggestion[];
|
items: PlayerSuggestion[];
|
||||||
onPick: (item: PlayerSuggestion) => void;
|
onPick: (item: PlayerSuggestion) => void;
|
||||||
|
// Необязательная строка сброса сверху списка («— игрок —» в PlayerSelect).
|
||||||
|
onClear?: () => void;
|
||||||
}) {
|
}) {
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0 && !onClear) return null;
|
||||||
return (
|
return (
|
||||||
<div className="suggest-list" role="listbox">
|
<div className="suggest-list" role="listbox">
|
||||||
|
{onClear && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="suggest-row suggest-clear"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onClear();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
— игрок —
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{items.map((u) => (
|
{items.map((u) => (
|
||||||
<button
|
<button
|
||||||
key={u.user_id}
|
key={u.user_id}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
|
import { PlayerSelect } from "../components/PlayerSelect";
|
||||||
import { Spinner } from "../components/Spinner";
|
import { Spinner } from "../components/Spinner";
|
||||||
import { MAX_MATCH_PLAYERS } from "../domain/constants";
|
import { MAX_MATCH_PLAYERS } from "../domain/constants";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
@@ -55,6 +56,13 @@ export function CreateMatchPage() {
|
|||||||
.map((r) => r.faction_id)
|
.map((r) => r.faction_id)
|
||||||
.filter((x): x is number => typeof x === "number");
|
.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>) =>
|
const update = (idx: number, patch: Partial<Draft>) =>
|
||||||
setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
||||||
|
|
||||||
@@ -181,17 +189,17 @@ export function CreateMatchPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<select
|
<PlayerSelect
|
||||||
value={row.user_id}
|
value={row.user_id}
|
||||||
onChange={(e) => update(idx, { user_id: e.target.value ? Number(e.target.value) : "" })}
|
options={memberOptions
|
||||||
>
|
.filter((m) => !takenUserIds(idx).includes(m.user_id))
|
||||||
<option value="">— игрок —</option>
|
.map((m) => ({
|
||||||
{memberOptions.map((m) => (
|
user_id: m.user_id,
|
||||||
<option key={m.user_id} value={m.user_id}>
|
nickname: m.nickname,
|
||||||
{m.nickname}
|
avatar_url: m.avatar_url,
|
||||||
</option>
|
}))}
|
||||||
))}
|
onPick={(id) => update(idx, { user_id: id })}
|
||||||
</select>
|
/>
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
{spin[idx] != null ? (
|
{spin[idx] != null ? (
|
||||||
|
|||||||
@@ -321,6 +321,29 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
.suggest-row:last-child { border-bottom: none; }
|
.suggest-row:last-child { border-bottom: none; }
|
||||||
.suggest-row:hover, .suggest-row:active { background: var(--surface-2); }
|
.suggest-row:hover, .suggest-row:active { background: var(--surface-2); }
|
||||||
.suggest-row .nick { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.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 {
|
.toast-top-wrap {
|
||||||
|
|||||||
Reference in New Issue
Block a user