Compare commits
3
Commits
dbc32b7f4d
...
ddbcd74058
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddbcd74058 | ||
|
|
e5b7420bae | ||
|
|
fc1b1fd912 |
@@ -173,3 +173,22 @@ def nickname_available(
|
|||||||
) -> s.NicknameAvailable:
|
) -> s.NicknameAvailable:
|
||||||
available = user_service.nickname_available(session, value.strip(), exclude_user_id=user.id)
|
available = user_service.nickname_available(session, value.strip(), exclude_user_id=user.id)
|
||||||
return s.NicknameAvailable(available=available)
|
return s.NicknameAvailable(available=available)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search", response_model=list[s.UserSuggestion])
|
||||||
|
def search_users(
|
||||||
|
q: str = Query("", max_length=64),
|
||||||
|
limit: int = Query(3, ge=1, le=10),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
_user: User = Depends(get_current_user),
|
||||||
|
) -> list[s.UserSuggestion]:
|
||||||
|
"""Автокомплит по нику. Пустой q — пустой список (не ошибка): удобнее при наборе."""
|
||||||
|
users = user_service.search_users(session, q, limit=limit)
|
||||||
|
return [
|
||||||
|
s.UserSuggestion(
|
||||||
|
user_id=u.id, # type: ignore[arg-type]
|
||||||
|
nickname=u.nickname,
|
||||||
|
avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
for u in users
|
||||||
|
]
|
||||||
|
|||||||
@@ -112,6 +112,14 @@ class NicknameAvailable(BaseModel):
|
|||||||
available: bool
|
available: bool
|
||||||
|
|
||||||
|
|
||||||
|
class UserSuggestion(BaseModel):
|
||||||
|
"""Подсказка автокомплита по нику (приглашение в группу и т.п.)."""
|
||||||
|
|
||||||
|
user_id: int
|
||||||
|
nickname: str
|
||||||
|
avatar_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
# ─── Группы и членство ───────────────────────────────────────────────────────
|
# ─── Группы и членство ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
class GroupCreate(BaseModel):
|
class GroupCreate(BaseModel):
|
||||||
|
|||||||
@@ -34,6 +34,28 @@ def nickname_available(session: Session, nickname: str, exclude_user_id: int | N
|
|||||||
return existing is None or existing.id == exclude_user_id
|
return existing is None or existing.id == exclude_user_id
|
||||||
|
|
||||||
|
|
||||||
|
def search_users(session: Session, query: str, limit: int = 3) -> list[User]:
|
||||||
|
"""Подсказки по нику: активные игроки (role='player'), без учёта регистра.
|
||||||
|
|
||||||
|
SQLite LIKE/lower() сворачивают регистр только для ASCII, а ники бывают
|
||||||
|
кириллицей — поэтому выбираем активных игроков и фильтруем/ранжируем в
|
||||||
|
Python (casefold). Пользователей десятки, полный проход дёшев; при росте
|
||||||
|
до тысяч — заменить на индексированную колонку nickname_lower.
|
||||||
|
Сначала префиксные совпадения, затем подстрочные, внутри — по алфавиту.
|
||||||
|
"""
|
||||||
|
q = (query or "").strip().casefold()
|
||||||
|
if not q:
|
||||||
|
return []
|
||||||
|
users = session.exec(
|
||||||
|
select(User).where(User.role == "player", User.is_active == True) # noqa: E712
|
||||||
|
).all()
|
||||||
|
matched = [u for u in users if q in u.nickname.casefold()]
|
||||||
|
matched.sort(
|
||||||
|
key=lambda u: (0 if u.nickname.casefold().startswith(q) else 1, u.nickname.casefold())
|
||||||
|
)
|
||||||
|
return matched[:limit]
|
||||||
|
|
||||||
|
|
||||||
def nickname_format_ok(nickname: str) -> bool:
|
def nickname_format_ok(nickname: str) -> bool:
|
||||||
"""Ник подходит по формату (2–64, буквы/цифры/пробел/.-_)."""
|
"""Ник подходит по формату (2–64, буквы/цифры/пробел/.-_)."""
|
||||||
return bool(_NICK_RE.match((nickname or "").strip()))
|
return bool(_NICK_RE.match((nickname or "").strip()))
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Поиск игроков для автокомплита по нику: GET /api/users/search."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.models import User
|
||||||
|
from tests.conftest import login
|
||||||
|
|
||||||
|
|
||||||
|
def _add_user(engine, nickname: str, *, active: bool = True, role: str = "player") -> None:
|
||||||
|
"""Готовим данные напрямую в БД: нужны выключенные аккаунты и админы."""
|
||||||
|
with Session(engine) as s:
|
||||||
|
s.add(
|
||||||
|
User(
|
||||||
|
nickname=nickname,
|
||||||
|
role=role,
|
||||||
|
auth_provider="stub" if role == "player" else "local",
|
||||||
|
is_active=active,
|
||||||
|
# CHECK ck_users_admin_has_password: админ обязан иметь пароль.
|
||||||
|
password_hash="x" if role == "admin" else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_prefix_first_cyrillic_case_insensitive(client: TestClient, engine):
|
||||||
|
login(client, "Искатель")
|
||||||
|
for nick in ("Барсик", "барс", "Морбарс", "Zed"):
|
||||||
|
_add_user(engine, nick)
|
||||||
|
|
||||||
|
r = client.get("/api/users/search", params={"q": "БАР"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
got = r.json()
|
||||||
|
# Префиксные раньше подстрочных, внутри — по алфавиту; регистр кириллицы не важен.
|
||||||
|
assert [u["nickname"] for u in got] == ["барс", "Барсик", "Морбарс"]
|
||||||
|
assert {"user_id", "nickname", "avatar_url"} <= set(got[0].keys())
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_empty_query_returns_empty_list(client: TestClient):
|
||||||
|
login(client, "Искатель")
|
||||||
|
r = client.get("/api/users/search", params={"q": " "})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_skips_inactive_and_admins(client: TestClient, engine):
|
||||||
|
login(client, "Искатель")
|
||||||
|
_add_user(engine, "Спящий барсук", active=False)
|
||||||
|
_add_user(engine, "адм барс", role="admin")
|
||||||
|
|
||||||
|
r = client.get("/api/users/search", params={"q": "барс"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_respects_limit(client: TestClient, engine):
|
||||||
|
login(client, "Искатель")
|
||||||
|
for i in range(5):
|
||||||
|
_add_user(engine, f"Игрок {i}")
|
||||||
|
|
||||||
|
assert len(client.get("/api/users/search", params={"q": "игрок"}).json()) == 3
|
||||||
|
assert (
|
||||||
|
len(client.get("/api/users/search", params={"q": "игрок", "limit": 5}).json()) == 5
|
||||||
|
)
|
||||||
@@ -7,6 +7,7 @@ export const qk = {
|
|||||||
factions: ["factions"] as const,
|
factions: ["factions"] as const,
|
||||||
myStats: ["myStats"] as const,
|
myStats: ["myStats"] as const,
|
||||||
publicProfile: (id: number) => ["publicProfile", id] as const,
|
publicProfile: (id: number) => ["publicProfile", id] as const,
|
||||||
|
userSearch: (q: string, limit: number) => ["userSearch", q, limit] as const,
|
||||||
groups: ["groups"] as const,
|
groups: ["groups"] as const,
|
||||||
group: (id: number) => ["group", id] as const,
|
group: (id: number) => ["group", id] as const,
|
||||||
groupFactions: (id: number) => ["group", id, "factions"] as const,
|
groupFactions: (id: number) => ["group", id, "factions"] as const,
|
||||||
|
|||||||
Vendored
+64
@@ -225,6 +225,26 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/users/search": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Search Users
|
||||||
|
* @description Автокомплит по нику. Пустой q — пустой список (не ошибка): удобнее при наборе.
|
||||||
|
*/
|
||||||
|
get: operations["search_users_api_users_search_get"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/groups": {
|
"/api/groups": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1975,6 +1995,18 @@ export interface components {
|
|||||||
/** Avatar Url */
|
/** Avatar Url */
|
||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* UserSuggestion
|
||||||
|
* @description Подсказка автокомплита по нику (приглашение в группу и т.п.).
|
||||||
|
*/
|
||||||
|
UserSuggestion: {
|
||||||
|
/** User Id */
|
||||||
|
user_id: number;
|
||||||
|
/** Nickname */
|
||||||
|
nickname: string;
|
||||||
|
/** Avatar Url */
|
||||||
|
avatar_url?: string | null;
|
||||||
|
};
|
||||||
/** ValidationError */
|
/** ValidationError */
|
||||||
ValidationError: {
|
ValidationError: {
|
||||||
/** Location */
|
/** Location */
|
||||||
@@ -2388,6 +2420,38 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
search_users_api_users_search_get: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
q?: string;
|
||||||
|
limit?: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UserSuggestion"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
my_groups_api_groups_get: {
|
my_groups_api_groups_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -6,11 +6,17 @@ function pct(v: number | null | undefined): string {
|
|||||||
return v == null ? "—" : `${Math.round(v * 100)}%`;
|
return v == null ? "—" : `${Math.round(v * 100)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Row({ entry, provisional }: { entry: LeaderboardEntry; provisional?: boolean }) {
|
function Row({
|
||||||
return (
|
entry,
|
||||||
// Вся строка — ссылка на профиль (на главной карточка-обёртка ведёт в общую
|
provisional,
|
||||||
// статистику, а клик по строке игрока — в его профиль).
|
profileLinks,
|
||||||
<PlayerLink userId={entry.user_id} className="lb-row">
|
}: {
|
||||||
|
entry: LeaderboardEntry;
|
||||||
|
provisional?: boolean;
|
||||||
|
profileLinks: boolean;
|
||||||
|
}) {
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
<div className={"lb-rank" + (entry.rank && entry.rank <= 3 ? " top" : "")}>
|
<div className={"lb-rank" + (entry.rank && entry.rank <= 3 ? " top" : "")}>
|
||||||
{entry.rank ?? "—"}
|
{entry.rank ?? "—"}
|
||||||
</div>
|
</div>
|
||||||
@@ -24,6 +30,14 @@ function Row({ entry, provisional }: { entry: LeaderboardEntry; provisional?: bo
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={"lb-score" + (provisional ? " provisional" : "")}>{entry.score ?? "—"}</div>
|
<div className={"lb-score" + (provisional ? " provisional" : "")}>{entry.score ?? "—"}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
// Строка-ссылка на профиль — только там, где сама карточка никуда не ведёт
|
||||||
|
// (на главной карточка топа целиком открывает общую статистику).
|
||||||
|
if (!profileLinks) return <div className="lb-row">{content}</div>;
|
||||||
|
return (
|
||||||
|
<PlayerLink userId={entry.user_id} className="lb-row">
|
||||||
|
{content}
|
||||||
</PlayerLink>
|
</PlayerLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -32,10 +46,12 @@ export function Leaderboard({
|
|||||||
entries,
|
entries,
|
||||||
provisional,
|
provisional,
|
||||||
inactive,
|
inactive,
|
||||||
|
profileLinks = true,
|
||||||
}: {
|
}: {
|
||||||
entries: LeaderboardEntry[];
|
entries: LeaderboardEntry[];
|
||||||
provisional?: LeaderboardEntry[];
|
provisional?: LeaderboardEntry[];
|
||||||
inactive?: LeaderboardEntry[];
|
inactive?: LeaderboardEntry[];
|
||||||
|
profileLinks?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const empty =
|
const empty =
|
||||||
entries.length === 0 && !provisional?.length && !inactive?.length;
|
entries.length === 0 && !provisional?.length && !inactive?.length;
|
||||||
@@ -45,7 +61,7 @@ export function Leaderboard({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{entries.map((e) => (
|
{entries.map((e) => (
|
||||||
<Row key={e.user_id} entry={e} />
|
<Row key={e.user_id} entry={e} profileLinks={profileLinks} />
|
||||||
))}
|
))}
|
||||||
{provisional && provisional.length > 0 && (
|
{provisional && provisional.length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -53,7 +69,7 @@ export function Leaderboard({
|
|||||||
Новички (мало игр)
|
Новички (мало игр)
|
||||||
</div>
|
</div>
|
||||||
{provisional.map((e) => (
|
{provisional.map((e) => (
|
||||||
<Row key={e.user_id} entry={e} provisional />
|
<Row key={e.user_id} entry={e} provisional profileLinks={profileLinks} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -63,7 +79,7 @@ export function Leaderboard({
|
|||||||
Ещё не играли
|
Ещё не играли
|
||||||
</div>
|
</div>
|
||||||
{inactive.map((e) => (
|
{inactive.map((e) => (
|
||||||
<Row key={e.user_id} entry={e} />
|
<Row key={e.user_id} entry={e} profileLinks={profileLinks} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Avatar } from "./Avatar";
|
||||||
|
|
||||||
|
export interface PlayerSuggestion {
|
||||||
|
user_id: number;
|
||||||
|
nickname: string;
|
||||||
|
avatar_url?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выпадающие подсказки ников под инпутом. Родитель оборачивает инпут в
|
||||||
|
* .suggest-wrap, управляет видимостью и решает, что делать по выбору
|
||||||
|
* (подставить ник, перейти в профиль и т.п.).
|
||||||
|
*/
|
||||||
|
export function PlayerSuggestions({
|
||||||
|
items,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
items: PlayerSuggestion[];
|
||||||
|
onPick: (item: PlayerSuggestion) => void;
|
||||||
|
}) {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="suggest-list" role="listbox">
|
||||||
|
{items.map((u) => (
|
||||||
|
<button
|
||||||
|
key={u.user_id}
|
||||||
|
type="button"
|
||||||
|
className="suggest-row"
|
||||||
|
role="option"
|
||||||
|
// mousedown, а не click: mousedown срабатывает ДО blur инпута — иначе
|
||||||
|
// onBlur родителя скрыл бы список раньше клика. preventDefault
|
||||||
|
// оставляет фокус в инпуте.
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onPick(u);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar url={u.avatar_url} nickname={u.nickname} size={28} />
|
||||||
|
<span className="nick">{u.nickname}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { api, unwrap } from "../api/client";
|
||||||
|
import { qk } from "../api/queryKeys";
|
||||||
|
|
||||||
|
/** Значение с задержкой: не дёргаем поиск на каждый введённый символ. */
|
||||||
|
function useDebounced<T>(value: T, ms = 250): T {
|
||||||
|
const [debounced, setDebounced] = useState(value);
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebounced(value), ms);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [value, ms]);
|
||||||
|
return debounced;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подсказки по нику среди всех активных игроков (GET /api/users/search). */
|
||||||
|
export function useUserSearch(q: string, limit = 3) {
|
||||||
|
const dq = useDebounced(q.trim());
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.userSearch(dq, limit),
|
||||||
|
enabled: dq.length > 0,
|
||||||
|
// Список не мигает пустым между буквами; при пустом q данные остаются —
|
||||||
|
// видимость выпадашки родитель гейтит по q, а не по data.
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
staleTime: 30_000,
|
||||||
|
queryFn: async () =>
|
||||||
|
unwrap(await api.GET("/api/users/search", { params: { query: { q: dq, limit } } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { GroupInvitations } from "../components/GroupInvitations";
|
|||||||
import { GroupSwitcher } from "../components/GroupSwitcher";
|
import { GroupSwitcher } from "../components/GroupSwitcher";
|
||||||
import { Leaderboard } from "../components/Leaderboard";
|
import { Leaderboard } from "../components/Leaderboard";
|
||||||
import { MatchListView } from "../components/MatchList";
|
import { MatchListView } from "../components/MatchList";
|
||||||
|
import { PlayerSuggestions } from "../components/PlayerSuggestions";
|
||||||
import { Spinner } from "../components/Spinner";
|
import { Spinner } from "../components/Spinner";
|
||||||
import { MAX_GROUP_SIZE } from "../domain/constants";
|
import { MAX_GROUP_SIZE } from "../domain/constants";
|
||||||
import { formatDate } from "../domain/format";
|
import { formatDate } from "../domain/format";
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
useGroupStats,
|
useGroupStats,
|
||||||
useInviteMember,
|
useInviteMember,
|
||||||
} from "../hooks/groups";
|
} from "../hooks/groups";
|
||||||
|
import { useUserSearch } from "../hooks/users";
|
||||||
|
|
||||||
export function GroupPage() {
|
export function GroupPage() {
|
||||||
const { data: me, isLoading } = useMe();
|
const { data: me, isLoading } = useMe();
|
||||||
@@ -33,6 +35,12 @@ export function GroupPage() {
|
|||||||
const inviteMember = useInviteMember();
|
const inviteMember = useInviteMember();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [newNick, setNewNick] = useState("");
|
const [newNick, setNewNick] = useState("");
|
||||||
|
const [nickFocused, setNickFocused] = useState(false);
|
||||||
|
// После подстановки ника из подсказки список прячем: подставленный ник сам
|
||||||
|
// совпадает с собой и без флага выпадашка вернулась бы сразу.
|
||||||
|
const [nickPicked, setNickPicked] = useState(false);
|
||||||
|
// limit с запасом: уже состоящих в группе отфильтруем ниже, до 3 подсказок.
|
||||||
|
const { data: foundUsers } = useUserSearch(newNick, 10);
|
||||||
|
|
||||||
if (isLoading) return <Spinner />;
|
if (isLoading) return <Spinner />;
|
||||||
|
|
||||||
@@ -48,6 +56,12 @@ export function GroupPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Подсказки: уже состоящих в группе не предлагаем, показываем до 3.
|
||||||
|
const memberIds = new Set((members ?? []).map((m) => m.user_id));
|
||||||
|
const suggestions = (foundUsers ?? []).filter((u) => !memberIds.has(u.user_id)).slice(0, 3);
|
||||||
|
const showSuggest =
|
||||||
|
nickFocused && !nickPicked && newNick.trim().length > 0 && suggestions.length > 0;
|
||||||
|
|
||||||
if (!groupId) {
|
if (!groupId) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -99,14 +113,31 @@ export function GroupPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="row" style={{ marginTop: 10 }}>
|
<div className="row" style={{ marginTop: 10 }}>
|
||||||
<input
|
<div className="suggest-wrap">
|
||||||
placeholder="Ник игрока — отправить приглашение"
|
<input
|
||||||
value={newNick}
|
placeholder="Ник игрока — отправить приглашение"
|
||||||
onChange={(e) => setNewNick(e.target.value)}
|
value={newNick}
|
||||||
onKeyDown={(e) => {
|
onChange={(e) => {
|
||||||
if (e.key === "Enter") invite();
|
setNewNick(e.target.value);
|
||||||
}}
|
setNickPicked(false);
|
||||||
/>
|
}}
|
||||||
|
onFocus={() => setNickFocused(true)}
|
||||||
|
onBlur={() => setNickFocused(false)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") invite();
|
||||||
|
if (e.key === "Escape") e.currentTarget.blur();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{showSuggest && (
|
||||||
|
<PlayerSuggestions
|
||||||
|
items={suggestions}
|
||||||
|
onPick={(u) => {
|
||||||
|
setNewNick(u.nickname);
|
||||||
|
setNickPicked(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button className="btn btn-primary" onClick={invite} disabled={inviteMember.isPending}>
|
<button className="btn btn-primary" onClick={invite} disabled={inviteMember.isPending}>
|
||||||
<UserPlus size={18} />
|
<UserPlus size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -15,15 +15,15 @@ export function HomePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Карточка кликабельна целиком → общая статистика; клик по нику внутри
|
{/* Карточка кликабельна целиком → общая статистика; ники внутри — просто
|
||||||
ведёт в профиль (Leaderboard останавливает всплытие на ссылках ников). */}
|
текст, ссылки на профили есть в самой общей статистике. */}
|
||||||
<div className="card" style={{ cursor: "pointer" }} onClick={() => navigate("/stats")}>
|
<div className="card" style={{ cursor: "pointer" }} onClick={() => navigate("/stats")}>
|
||||||
<div className="row-between">
|
<div className="row-between">
|
||||||
<h3 style={{ margin: 0 }}>Топ игроков</h3>
|
<h3 style={{ margin: 0 }}>Топ игроков</h3>
|
||||||
<span className="muted small">Вся статистика →</span>
|
<span className="muted small">Вся статистика →</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 10 }}>
|
<div style={{ marginTop: 10 }}>
|
||||||
<Leaderboard entries={data.leaderboard} provisional={data.provisional} />
|
<Leaderboard entries={data.leaderboard} provisional={data.provisional} profileLinks={false} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -289,6 +289,39 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
.notif-item-body { margin-top: 2px; }
|
.notif-item-body { margin-top: 2px; }
|
||||||
.notif-item-time { margin-top: 4px; }
|
.notif-item-time { margin-top: 4px; }
|
||||||
|
|
||||||
|
/* Подсказки ников под инпутом (приглашение игрока и т.п.) */
|
||||||
|
.suggest-wrap { position: relative; flex: 1; min-width: 0; }
|
||||||
|
.suggest-list {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 50;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.suggest-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
|
|
||||||
/* Всплывашки сверху экрана в момент прихода */
|
/* Всплывашки сверху экрана в момент прихода */
|
||||||
.toast-top-wrap {
|
.toast-top-wrap {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
Reference in New Issue
Block a user