From f22ee1ec295ec5d84a5ea5ad460650305a1b5d8b Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sat, 18 Jul 2026 03:00:26 +0300 Subject: [PATCH 1/5] =?UTF-8?q?UI:=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20?= =?UTF-8?q?=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=B0=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B8=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=B8=D0=B8=20=E2=80=94=20=D0=B2=D1=8B=D0=BF=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D1=88=D0=BA=D0=B0=20=D0=B2=20=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D0=BB=D0=B5=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BA=D0=B0=D0=B7=D0=BE?= =?UTF-8?q?=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вместо нативного select — кнопка-триггер (аватар + ник) с выпадающим списком всех игроков группы в стилистике подсказок инвайта; игроки, уже выбранные в других строках, не предлагаются (заодно исключены дубли). Для аватаров в списке MemberRead теперь отдаёт avatar_url. Co-Authored-By: Claude Fable 5 --- backend/app/routers/groups.py | 19 ++++-- backend/app/schemas/api.py | 1 + frontend/src/api/schema.d.ts | 2 + frontend/src/components/PlayerSelect.tsx | 63 +++++++++++++++++++ frontend/src/components/PlayerSuggestions.tsx | 17 ++++- frontend/src/pages/CreateMatchPage.tsx | 28 ++++++--- frontend/src/styles/global.css | 23 +++++++ 7 files changed, 137 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/PlayerSelect.tsx 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 { From 8a2b53d0b285784df3ca7c5330af04bf9d928f45 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sat, 18 Jul 2026 03:13:05 +0300 Subject: [PATCH 2/5] =?UTF-8?q?UI:=20=D0=B4=D0=BE=D0=B2=D0=BE=D0=B4=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B2=D1=8B=D0=BF=D0=B0=D0=B4=D0=B0=D1=88=D0=B5=D0=BA?= =?UTF-8?q?=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=BE=D0=B2=20=E2=80=94=20?= =?UTF-8?q?=D0=B1=D0=B5=D0=B7=20=D0=BF=D1=83=D1=81=D1=82=D1=8B=D1=85=20?= =?UTF-8?q?=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82=D0=BE=D0=B2,=20?= =?UTF-8?q?=D1=84=D0=BE=D0=BD=20=D0=BA=D0=B0=D0=BA=20=D1=83=20select,=20?= =?UTF-8?q?=D0=B1=D1=8B=D1=81=D1=82=D1=80=D0=B5=D0=B5=20=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=BB=D0=B8=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Из списков убраны варианты-плейсхолдеры («— игрок —» в выборе игрока, «— фракция —» в выборе фракции — disabled+hidden, в закрытом виде текст остаётся). Фон .suggest-list — surface-2, как у input/select; hover строк — --border. Скорость: дебаунс поиска 250→100 мс, в инвайте показанный список дофильтровывается по вводу на клиенте, не дожидаясь ответа сервера; touch-action: manipulation на строках и триггере. Co-Authored-By: Claude Fable 5 --- frontend/src/components/PlayerSelect.tsx | 10 +--------- frontend/src/components/PlayerSuggestions.tsx | 17 +---------------- frontend/src/hooks/users.ts | 5 +++-- frontend/src/pages/CreateMatchPage.tsx | 6 +++++- frontend/src/pages/GroupPage.tsx | 7 ++++++- frontend/src/styles/global.css | 7 ++++--- 6 files changed, 20 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/PlayerSelect.tsx b/frontend/src/components/PlayerSelect.tsx index 8e4afeb..8c9612e 100644 --- a/frontend/src/components/PlayerSelect.tsx +++ b/frontend/src/components/PlayerSelect.tsx @@ -16,7 +16,7 @@ export function PlayerSelect({ }: { value: number | ""; options: PlayerSuggestion[]; - onPick: (userId: number | "") => void; + onPick: (userId: number) => void; }) { const [open, setOpen] = useState(false); const selected = value === "" ? null : options.find((o) => o.user_id === value) ?? null; @@ -48,14 +48,6 @@ export function PlayerSelect({ 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 1c165ac..feb078d 100644 --- a/frontend/src/components/PlayerSuggestions.tsx +++ b/frontend/src/components/PlayerSuggestions.tsx @@ -14,28 +14,13 @@ export interface PlayerSuggestion { export function PlayerSuggestions({ items, onPick, - onClear, }: { items: PlayerSuggestion[]; onPick: (item: PlayerSuggestion) => void; - // Необязательная строка сброса сверху списка («— игрок —» в PlayerSelect). - onClear?: () => void; }) { - if (items.length === 0 && !onClear) return null; + if (items.length === 0) return null; return (
- {onClear && ( - - )} {items.map((u) => ( + {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 })} + /> )}
+ -
-

Пример (стол на 4 игроков)

-
-
1-е место(4−1−0)/3 = 1.00 → 100
-
2-е место(4−2−0)/3 = 0.67 → 67
-
3-е место(4−3−0)/3 = 0.33 → 33
-
4-е место(4−4−0)/3 = 0.00 → 0
-
- Ничья за 1-е (двое)(4−1−0.5)/3 = 0.83 каждому + {showRating && ( + <> +
+

Очки игроков (рейтинг)

+

+ Рейтинг считается только по завершённым партиям. Незавершённые и отменённые + в зачёт не идут. +

+

+ За каждую партию игрок получает «очки за место» — они нормированы по числу игроков + за столом, поэтому победа за большим столом ценится выше, чем за маленьким: +

+
+ очки = (N − место − (размер_ничьей − 1) / 2) / (N − 1) +
+

+ где N — число игроков в партии. Первое место даёт 1.0, последнее — 0.0. +

+
    +
  • Если несколько игроков делят место (ничья) — они делят сумму очков поровну.
  • +
  • Выбывшие из партии считаются как последнее место.
  • +
+

+ Итоговый рейтинг игрока — сглаженное среднее: к реальным партиям + «дописываются» 10 виртуальных со средним результатом 0.5 (шкала от 0 до 100): +

+
+ рейтинг = (10 × 0.5 + сумма очков) / (10 + число партий) × 100 +
+

+ Пока партий мало, рейтинг держится около 50 и с опытом сходится к реальному + среднему — стабильные результаты на длинной дистанции ценятся выше короткой + удачной серии. +

-
-
-
-

Попадание в топ

-

- Чтобы попасть в ранжированный топ, нужно сыграть минимум 10 партий. До этого - игрок показывается в блоке «Новички» — предварительно, без места в рейтинге. -

-
+
+

Пример (стол на 4 игроков)

+
+
1-е место(4−1−0)/3 = 1.00 → 100
+
2-е место(4−2−0)/3 = 0.67 → 67
+
3-е место(4−3−0)/3 = 0.33 → 33
+
4-е место(4−4−0)/3 = 0.00 → 0
+
+ Ничья за 1-е (двое)(4−1−0.5)/3 = 0.83 каждому +
+
+
-
-

Другие показатели

-
    -
  • Винрейт (WR) — доля партий, в которых игрок занял 1-е место.
  • -
  • Среднее место — среднее по завершённым партиям.
  • -
  • - При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту - и затем по нику. -
  • -
-
+
+

Попадание в топ

+

+ Чтобы попасть в ранжированный топ, нужно сыграть минимум 10 партий. До этого + игрок показывается в блоке «Новички» — предварительно, без места в рейтинге. +

+
+ +
+

Другие показатели

+
    +
  • Винрейт (WR) — доля партий, в которых игрок занял 1-е место.
  • +
  • Среднее место — среднее по завершённым партиям.
  • +
  • + При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту + и затем по нику. +
  • +
+
+ + )}
); } From 1dbff7ae58ee9af6333377d6d37dd8fd3d8c3cd9 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sat, 18 Jul 2026 03:42:30 +0300 Subject: [PATCH 5/5] =?UTF-8?q?=D0=A1=D0=BE=D0=BA=D1=80=D0=B0=D1=89=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BD=D0=B0=D0=B4=D0=BF=D0=B8=D1=81=D1=8C-?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D1=81=D0=BA=D0=B0=D0=B7=D0=BA=D0=B0=20=D0=B2?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=BB=D0=B5=20=D0=B2=D0=B2=D0=BE=D0=B4=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=D0=B0=20=D0=BF=D1=80=D0=B8=D0=B3=D0=BB=D0=B0?= =?UTF-8?q?=D1=88=D0=B0=D0=B5=D0=BC=D0=BE=D0=B3=D0=BE=20=D0=B2=20=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=BF=D0=BF=D1=83=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/GroupPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/GroupPage.tsx b/frontend/src/pages/GroupPage.tsx index 4519f9b..783fdb7 100644 --- a/frontend/src/pages/GroupPage.tsx +++ b/frontend/src/pages/GroupPage.tsx @@ -120,7 +120,7 @@ export function GroupPage() {
{ setNewNick(e.target.value);