v1.21 — Полировка выпадающих списков в меню начала партии
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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,72 +17,88 @@ export function HelpPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
{/* Кнопка-карточка раздела — как карточки списка партий. */}
|
||||||
<h3>Очки игроков (рейтинг)</h3>
|
<button
|
||||||
<p className="small">
|
className="card"
|
||||||
Рейтинг считается только по <b>завершённым</b> партиям. Незавершённые и отменённые
|
style={{ textAlign: "left", width: "100%" }}
|
||||||
в зачёт не идут.
|
onClick={() => setShowRating((v) => !v)}
|
||||||
</p>
|
>
|
||||||
<p className="small">
|
<div className="row-between">
|
||||||
За каждую партию игрок получает «очки за место» — они нормированы по числу игроков
|
<h3 style={{ margin: 0 }}>Рейтинг</h3>
|
||||||
за столом, поэтому победа за большим столом ценится выше, чем за маленьким:
|
{showRating ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||||
</p>
|
|
||||||
<div className="formula">
|
|
||||||
очки = (N − место − (размер_ничьей − 1) / 2) / (N − 1)
|
|
||||||
</div>
|
</div>
|
||||||
<p className="small" style={{ marginTop: 10 }}>
|
</button>
|
||||||
где <b>N</b> — число игроков в партии. Первое место даёт <b>1.0</b>, последнее — <b>0.0</b>.
|
|
||||||
</p>
|
|
||||||
<ul className="small" style={{ marginTop: 0, paddingLeft: 18 }}>
|
|
||||||
<li>Если несколько игроков делят место (ничья) — они делят сумму очков поровну.</li>
|
|
||||||
<li>Выбывшие из партии считаются как последнее место.</li>
|
|
||||||
</ul>
|
|
||||||
<p className="small">
|
|
||||||
Итоговый <b>рейтинг</b> игрока — сглаженное среднее: к реальным партиям
|
|
||||||
«дописываются» 10 виртуальных со средним результатом 0.5 (шкала от 0 до 100):
|
|
||||||
</p>
|
|
||||||
<div className="formula">
|
|
||||||
рейтинг = (10 × 0.5 + сумма очков) / (10 + число партий) × 100
|
|
||||||
</div>
|
|
||||||
<p className="small" style={{ marginTop: 10 }}>
|
|
||||||
Пока партий мало, рейтинг держится около 50 и с опытом сходится к реальному
|
|
||||||
среднему — стабильные результаты на длинной дистанции ценятся выше короткой
|
|
||||||
удачной серии.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card">
|
{showRating && (
|
||||||
<h3>Пример (стол на 4 игроков)</h3>
|
<>
|
||||||
<div className="stack" style={{ gap: 6 }}>
|
<div className="card">
|
||||||
<div className="row-between small"><span>1-е место</span><b>(4−1−0)/3 = 1.00 → 100</b></div>
|
<h3>Очки игроков (рейтинг)</h3>
|
||||||
<div className="row-between small"><span>2-е место</span><b>(4−2−0)/3 = 0.67 → 67</b></div>
|
<p className="small">
|
||||||
<div className="row-between small"><span>3-е место</span><b>(4−3−0)/3 = 0.33 → 33</b></div>
|
Рейтинг считается только по <b>завершённым</b> партиям. Незавершённые и отменённые
|
||||||
<div className="row-between small"><span>4-е место</span><b>(4−4−0)/3 = 0.00 → 0</b></div>
|
в зачёт не идут.
|
||||||
<div className="row-between small">
|
</p>
|
||||||
<span>Ничья за 1-е (двое)</span><b>(4−1−0.5)/3 = 0.83 каждому</b>
|
<p className="small">
|
||||||
|
За каждую партию игрок получает «очки за место» — они нормированы по числу игроков
|
||||||
|
за столом, поэтому победа за большим столом ценится выше, чем за маленьким:
|
||||||
|
</p>
|
||||||
|
<div className="formula">
|
||||||
|
очки = (N − место − (размер_ничьей − 1) / 2) / (N − 1)
|
||||||
|
</div>
|
||||||
|
<p className="small" style={{ marginTop: 10 }}>
|
||||||
|
где <b>N</b> — число игроков в партии. Первое место даёт <b>1.0</b>, последнее — <b>0.0</b>.
|
||||||
|
</p>
|
||||||
|
<ul className="small" style={{ marginTop: 0, paddingLeft: 18 }}>
|
||||||
|
<li>Если несколько игроков делят место (ничья) — они делят сумму очков поровну.</li>
|
||||||
|
<li>Выбывшие из партии считаются как последнее место.</li>
|
||||||
|
</ul>
|
||||||
|
<p className="small">
|
||||||
|
Итоговый <b>рейтинг</b> игрока — сглаженное среднее: к реальным партиям
|
||||||
|
«дописываются» 10 виртуальных со средним результатом 0.5 (шкала от 0 до 100):
|
||||||
|
</p>
|
||||||
|
<div className="formula">
|
||||||
|
рейтинг = (10 × 0.5 + сумма очков) / (10 + число партий) × 100
|
||||||
|
</div>
|
||||||
|
<p className="small" style={{ marginTop: 10 }}>
|
||||||
|
Пока партий мало, рейтинг держится около 50 и с опытом сходится к реальному
|
||||||
|
среднему — стабильные результаты на длинной дистанции ценятся выше короткой
|
||||||
|
удачной серии.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Попадание в топ</h3>
|
<h3>Пример (стол на 4 игроков)</h3>
|
||||||
<p className="small">
|
<div className="stack" style={{ gap: 6 }}>
|
||||||
Чтобы попасть в ранжированный топ, нужно сыграть минимум <b>10 партий</b>. До этого
|
<div className="row-between small"><span>1-е место</span><b>(4−1−0)/3 = 1.00 → 100</b></div>
|
||||||
игрок показывается в блоке «Новички» — предварительно, без места в рейтинге.
|
<div className="row-between small"><span>2-е место</span><b>(4−2−0)/3 = 0.67 → 67</b></div>
|
||||||
</p>
|
<div className="row-between small"><span>3-е место</span><b>(4−3−0)/3 = 0.33 → 33</b></div>
|
||||||
</div>
|
<div className="row-between small"><span>4-е место</span><b>(4−4−0)/3 = 0.00 → 0</b></div>
|
||||||
|
<div className="row-between small">
|
||||||
|
<span>Ничья за 1-е (двое)</span><b>(4−1−0.5)/3 = 0.83 каждому</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Другие показатели</h3>
|
<h3>Попадание в топ</h3>
|
||||||
<ul className="small" style={{ margin: 0, paddingLeft: 18 }}>
|
<p className="small">
|
||||||
<li><b>Винрейт (WR)</b> — доля партий, в которых игрок занял 1-е место.</li>
|
Чтобы попасть в ранжированный топ, нужно сыграть минимум <b>10 партий</b>. До этого
|
||||||
<li><b>Среднее место</b> — среднее по завершённым партиям.</li>
|
игрок показывается в блоке «Новички» — предварительно, без места в рейтинге.
|
||||||
<li>
|
</p>
|
||||||
При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту
|
</div>
|
||||||
и затем по нику.
|
|
||||||
</li>
|
<div className="card">
|
||||||
</ul>
|
<h3>Другие показатели</h3>
|
||||||
</div>
|
<ul className="small" style={{ margin: 0, paddingLeft: 18 }}>
|
||||||
|
<li><b>Винрейт (WR)</b> — доля партий, в которых игрок занял 1-е место.</li>
|
||||||
|
<li><b>Среднее место</b> — среднее по завершённым партиям.</li>
|
||||||
|
<li>
|
||||||
|
При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту
|
||||||
|
и затем по нику.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user