v1.21 — Полировка выпадающих списков в меню начала партии

This commit is contained in:
2026-07-18 03:48:36 +03:00
9 changed files with 243 additions and 102 deletions
+14 -5
View File
@@ -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),
)
# ─── Партии и статистика группы ────────────────────────────────────────────── # ─── Партии и статистика группы ──────────────────────────────────────────────
+1
View File
@@ -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):
+2
View File
@@ -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: {
+69
View File
@@ -0,0 +1,69 @@
import { ChevronDown } from "lucide-react";
import { type ReactNode, useState } from "react";
/**
* Выпадающий выбор «как select» в стиле подсказок ников (.suggest-*):
* кнопка-триггер с выбранным значением, под ней список опций. Родитель сам
* фильтрует options (например, убирает выбранное в других строках) и передаёт
* selected, найденный в ПОЛНОМ списке: выбранное значение может отсутствовать
* в options (например, фракция занята другой строкой после «Рандом всем»).
*/
export function PickerSelect<T extends { id: number }>({
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 (
<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 ? (
(renderValue ?? renderOption)(selected)
) : (
<span className="picker-placeholder">{placeholder}</span>
)}
<ChevronDown size={16} className="picker-chevron" />
</button>
{open && options.length > 0 && (
<div className="suggest-list" role="listbox">
{options.map((o) => (
<button
key={o.id}
type="button"
className="suggest-row"
role="option"
// mousedown, а не click: mousedown срабатывает ДО blur триггера —
// иначе onBlur скрыл бы список раньше клика (см. PlayerSuggestions).
onMouseDown={(e) => {
e.preventDefault();
onPick(o);
setOpen(false);
}}
>
{renderOption(o)}
</button>
))}
</div>
)}
</div>
);
}
+3 -2
View File
@@ -4,8 +4,9 @@ import { useEffect, useState } from "react";
import { api, unwrap } from "../api/client"; import { api, unwrap } from "../api/client";
import { qk } from "../api/queryKeys"; import { qk } from "../api/queryKeys";
/** Значение с задержкой: не дёргаем поиск на каждый введённый символ. */ /** Значение с задержкой: не дёргаем поиск на каждый введённый символ.
function useDebounced<T>(value: T, ms = 250): T { * 100 мс — почти незаметно глазу, но склеивает быструю печать в один запрос. */
function useDebounced<T>(value: T, ms = 100): T {
const [debounced, setDebounced] = useState(value); const [debounced, setDebounced] = useState(value);
useEffect(() => { useEffect(() => {
const t = setTimeout(() => setDebounced(value), ms); const t = setTimeout(() => setDebounced(value), ms);
+38 -28
View File
@@ -3,6 +3,8 @@ 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 { Avatar } from "../components/Avatar";
import { PickerSelect } from "../components/PickerSelect";
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 +57,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)));
@@ -160,6 +169,20 @@ export function CreateMatchPage() {
const memberOptions = (members ?? []) as MemberRead[]; const memberOptions = (members ?? []) as MemberRead[];
const spinning = Object.values(spin).some((v) => v != null); 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) => (
<>
<Avatar url={m.avatar_url} nickname={m.nickname} size={size} />
<span className="nick">{m.nickname}</span>
</>
);
return ( return (
<div> <div>
<div className="card"> <div className="card">
@@ -181,39 +204,26 @@ export function CreateMatchPage() {
)} )}
</div> </div>
<select <PickerSelect
value={row.user_id} selected={playerOptions.find((m) => m.id === row.user_id) ?? null}
onChange={(e) => update(idx, { user_id: e.target.value ? Number(e.target.value) : "" })} options={playerOptions.filter((m) => !takenUserIds(idx).includes(m.id))}
> placeholder="— игрок —"
<option value="">— игрок —</option> renderOption={(m) => playerLabel(m, 28)}
{memberOptions.map((m) => ( renderValue={(m) => playerLabel(m, 24)}
<option key={m.user_id} value={m.user_id}> onPick={(m) => update(idx, { user_id: m.id })}
{m.nickname} />
</option>
))}
</select>
<div className="row"> <div className="row">
{spin[idx] != null ? ( {spin[idx] != null ? (
<div className="faction-spin" style={{ flex: 1 }}>{spin[idx]}</div> <div className="faction-spin" style={{ flex: 1 }}>{spin[idx]}</div>
) : ( ) : (
<select <PickerSelect
value={row.faction_id} selected={(factions ?? []).find((f) => f.id === row.faction_id) ?? null}
onChange={(e) => options={(factions ?? []).filter((f) => !takenFactionIds(idx).includes(f.id))}
update(idx, { placeholder="— фракция —"
faction_id: e.target.value ? Number(e.target.value) : "", renderOption={(f) => f.name_ru}
was_random: false, onPick={(f) => update(idx, { faction_id: f.id, was_random: false })}
}) />
}
style={{ flex: 1 }}
>
<option value="">— фракция —</option>
{(factions ?? []).map((f) => (
<option key={f.id} value={f.id}>
{f.name_ru}
</option>
))}
</select>
)} )}
<button <button
className="btn" className="btn"
+7 -2
View File
@@ -57,8 +57,13 @@ export function GroupPage() {
}; };
// Подсказки: уже состоящих в группе не предлагаем, показываем до 3. // Подсказки: уже состоящих в группе не предлагаем, показываем до 3.
// Дофильтровка по текущему вводу — на клиенте: пока сервер отвечает на новую
// букву, показанный список (keepPreviousData) сужается мгновенно.
const memberIds = new Set((members ?? []).map((m) => m.user_id)); const memberIds = new Set((members ?? []).map((m) => m.user_id));
const suggestions = (foundUsers ?? []).filter((u) => !memberIds.has(u.user_id)).slice(0, 3); const needle = newNick.trim().toLowerCase();
const suggestions = (foundUsers ?? [])
.filter((u) => !memberIds.has(u.user_id) && u.nickname.toLowerCase().includes(needle))
.slice(0, 3);
const showSuggest = const showSuggest =
nickFocused && !nickPicked && newNick.trim().length > 0 && suggestions.length > 0; nickFocused && !nickPicked && newNick.trim().length > 0 && suggestions.length > 0;
@@ -115,7 +120,7 @@ export function GroupPage() {
<div className="row" style={{ marginTop: 10 }}> <div className="row" style={{ marginTop: 10 }}>
<div className="suggest-wrap"> <div className="suggest-wrap">
<input <input
placeholder="Ник игрока — отправить приглашение" placeholder="Ник игрока — пригласить"
value={newNick} value={newNick}
onChange={(e) => { onChange={(e) => {
setNewNick(e.target.value); setNewNick(e.target.value);
+22 -2
View File
@@ -1,8 +1,12 @@
import { ChevronDown, ChevronUp } from "lucide-react";
import { useState } from "react";
/** /**
* Справка. Пока — только как считаются очки игроков (рейтинг). В дальнейшем сюда * Справка — разделы, раскрывающиеся по кнопке. Пока раздел один: «Рейтинг»
* добавятся правила, подсказки и прочее. Текст соответствует backend/app/services/scoring.py. * (как считаются очки игроков). Текст соответствует backend/app/services/scoring.py.
*/ */
export function HelpPage() { export function HelpPage() {
const [showRating, setShowRating] = useState(false);
return ( return (
<div> <div>
{/* Название страницы уже в верхней панели — в теле только пояснение. */} {/* Название страницы уже в верхней панели — в теле только пояснение. */}
@@ -13,6 +17,20 @@ export function HelpPage() {
</p> </p>
</div> </div>
{/* Кнопка-карточка раздела — как карточки списка партий. */}
<button
className="card"
style={{ textAlign: "left", width: "100%" }}
onClick={() => setShowRating((v) => !v)}
>
<div className="row-between">
<h3 style={{ margin: 0 }}>Рейтинг</h3>
{showRating ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
</div>
</button>
{showRating && (
<>
<div className="card"> <div className="card">
<h3>Очки игроков (рейтинг)</h3> <h3>Очки игроков (рейтинг)</h3>
<p className="small"> <p className="small">
@@ -79,6 +97,8 @@ export function HelpPage() {
</li> </li>
</ul> </ul>
</div> </div>
</>
)}
</div> </div>
); );
} }
+26 -2
View File
@@ -296,7 +296,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
top: calc(100% + 4px); top: calc(100% + 4px);
left: 0; left: 0;
right: 0; right: 0;
background: var(--surface); background: var(--surface-2); /* как у input/select — выпадашка продолжает поле */
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); 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; font-weight: 600;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
touch-action: manipulation; /* тап без задержки двойного тапа */
} }
.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(--border); }
.suggest-row .nick { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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 { .toast-top-wrap {
position: fixed; position: fixed;