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/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/hooks/users.ts b/frontend/src/hooks/users.ts index b52cf1b..191a328 100644 --- a/frontend/src/hooks/users.ts +++ b/frontend/src/hooks/users.ts @@ -4,8 +4,9 @@ import { useEffect, useState } from "react"; import { api, unwrap } from "../api/client"; import { qk } from "../api/queryKeys"; -/** Значение с задержкой: не дёргаем поиск на каждый введённый символ. */ -function useDebounced(value: T, ms = 250): T { +/** Значение с задержкой: не дёргаем поиск на каждый введённый символ. + * 100 мс — почти незаметно глазу, но склеивает быструю печать в один запрос. */ +function useDebounced(value: T, ms = 100): T { const [debounced, setDebounced] = useState(value); useEffect(() => { const t = setTimeout(() => setDebounced(value), ms); diff --git a/frontend/src/pages/CreateMatchPage.tsx b/frontend/src/pages/CreateMatchPage.tsx index 01f0aa9..e6aa1e9 100644 --- a/frontend/src/pages/CreateMatchPage.tsx +++ b/frontend/src/pages/CreateMatchPage.tsx @@ -3,6 +3,8 @@ import { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { ApiError } from "../api/client"; +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"; @@ -55,6 +57,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) => setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r))); @@ -160,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 (
@@ -181,39 +204,26 @@ export function CreateMatchPage() { )}
- + 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-е место.
  • +
  • Среднее место — среднее по завершённым партиям.
  • +
  • + При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту + и затем по нику. +
  • +
+
+ + )}
); } diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 096faf8..6eb94b1 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -296,7 +296,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); } top: calc(100% + 4px); left: 0; right: 0; - background: var(--surface); + background: var(--surface-2); /* как у input/select — выпадашка продолжает поле */ border: 1px solid var(--border); border-radius: var(--radius-sm); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); @@ -317,11 +317,35 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); } font-weight: 600; text-align: left; cursor: pointer; + touch-action: manipulation; /* тап без задержки двойного тапа */ } .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(--border); } .suggest-row .nick { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* Кнопка-триггер PickerSelect: выглядит как 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; + touch-action: manipulation; +} +.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 { position: fixed;