diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py index d4908cb..80ee642 100644 --- a/backend/app/routers/groups.py +++ b/backend/app/routers/groups.py @@ -17,6 +17,7 @@ from app.services import ( notification_service, notify, stats_service, + user_service, ) router = APIRouter(prefix="/groups", tags=["groups"]) @@ -127,7 +128,12 @@ def list_members( ) -> list[s.MemberRead]: group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type] 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) ] @@ -195,10 +201,13 @@ def change_member_role( group_service.assert_owner(session, group_id, user.id) # type: ignore[arg-type] group = group_service.get_group(session, group_id) member = membership_service.change_role(session, group, user_id, body.role) - from app.services.user_service import get_user - - u = get_user(session, user_id) - return s.MemberRead(user_id=u.id, nickname=u.nickname, role=member.role) + u = user_service.get_user(session, user_id) + return s.MemberRead( + user_id=u.id, + nickname=u.nickname, + role=member.role, + avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), + ) # ─── Партии и статистика группы ────────────────────────────────────────────── diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 84dcb6b..b8cc7eb 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -147,6 +147,7 @@ class MemberRead(BaseModel): user_id: int nickname: str role: str + avatar_url: str | None = None class MemberAdd(BaseModel): diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index b3f0f86..dbc5301 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -1778,6 +1778,8 @@ export interface components { nickname: string; /** Role */ role: string; + /** Avatar Url */ + avatar_url?: string | null; }; /** MemberRoleUpdate */ MemberRoleUpdate: { diff --git a/frontend/src/components/PlayerSelect.tsx b/frontend/src/components/PlayerSelect.tsx new file mode 100644 index 0000000..8e4afeb --- /dev/null +++ b/frontend/src/components/PlayerSelect.tsx @@ -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 ( +
+ + {open && ( + { + onPick(u.user_id); + setOpen(false); + }} + onClear={ + selected + ? () => { + onPick(""); + setOpen(false); + } + : undefined + } + /> + )} +
+ ); +} diff --git a/frontend/src/components/PlayerSuggestions.tsx b/frontend/src/components/PlayerSuggestions.tsx index feb078d..1c165ac 100644 --- a/frontend/src/components/PlayerSuggestions.tsx +++ b/frontend/src/components/PlayerSuggestions.tsx @@ -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 (
+ {onClear && ( + + )} {items.map((u) => (
- + 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 })} + />
{spin[idx] != null ? ( diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 096faf8..a300f48 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -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 {