v1.11 — QoL улучшения интерфейса, унификация его эллементов
This commit is contained in:
@@ -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):
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
# Порог числа игр для попадания в ранжированный топ (ниже — «Новички»/provisional).
|
# Порог числа игр для попадания в ранжированный топ (ниже — «Новички»/provisional).
|
||||||
MIN_GAMES = 3
|
MIN_GAMES = 5
|
||||||
# Порог числа игр на фракцию для расчёта лучшей/худшей фракции.
|
# Порог числа игр на фракцию для расчёта лучшей/худшей фракции.
|
||||||
FACTION_MIN_GAMES = 2
|
FACTION_MIN_GAMES = 2
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ApiError } from "../api/client";
|
|||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
import { useCreateGroup } from "../hooks/groups";
|
import { useCreateGroup } from "../hooks/groups";
|
||||||
import { useExpansions } from "../hooks/reference";
|
import { useExpansions } from "../hooks/reference";
|
||||||
|
import { Switch } from "./Switch";
|
||||||
|
|
||||||
export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
|
export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
|
||||||
const { data: expansions } = useExpansions();
|
const { data: expansions } = useExpansions();
|
||||||
@@ -47,9 +48,7 @@ export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
|
|||||||
{e.name_ru}
|
{e.name_ru}
|
||||||
{e.is_base && <span className="muted small"> (всегда включено)</span>}
|
{e.is_base && <span className="muted small"> (всегда включено)</span>}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<Switch
|
||||||
type="checkbox"
|
|
||||||
style={{ width: "auto" }}
|
|
||||||
disabled={e.is_base}
|
disabled={e.is_base}
|
||||||
checked={e.is_base || selected.has(e.id)}
|
checked={e.is_base || selected.has(e.id)}
|
||||||
onChange={() => toggle(e.id)}
|
onChange={() => toggle(e.id)}
|
||||||
@@ -59,7 +58,7 @@ export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="error-text">{error}</p>}
|
{error && <p className="error-text">{error}</p>}
|
||||||
<button className="btn btn-primary" style={{ width: "100%" }} onClick={submit} disabled={createGroup.isPending}>
|
<button className="btn btn-primary btn-block" onClick={submit} disabled={createGroup.isPending}>
|
||||||
Создать
|
Создать
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,17 +21,13 @@ export function GroupSwitcher() {
|
|||||||
className={"btn" + (active ? " btn-primary" : "")}
|
className={"btn" + (active ? " btn-primary" : "")}
|
||||||
onClick={() => !active && setActive.mutate(g.id)}
|
onClick={() => !active && setActive.mutate(g.id)}
|
||||||
>
|
>
|
||||||
{g.name} {active && "✓"} <span className="muted small"></span>
|
{g.name} {active && "✓"}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{/* Плашка создания новой группы — в одном ряду логики с выбором группы. */}
|
{/* Плашка создания новой группы — в одном ряду логики с выбором группы. */}
|
||||||
<button
|
<button className="btn" onClick={() => setCreating((v) => !v)}>
|
||||||
className="btn"
|
Создать группу
|
||||||
aria-label="Создать группу"
|
|
||||||
onClick={() => setCreating((v) => !v)}
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { formatTime } from "../domain/format";
|
import { formatTime, plural } from "../domain/format";
|
||||||
import type { HomeInProgressMatch } from "../domain/types";
|
import type { HomeInProgressMatch } from "../domain/types";
|
||||||
|
|
||||||
export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) {
|
export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) {
|
||||||
@@ -15,30 +15,25 @@ export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) {
|
|||||||
{items.map((m) => (
|
{items.map((m) => (
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className="row-between"
|
className="card row-between"
|
||||||
onClick={() => navigate(`/match/${m.id}`)}
|
onClick={() => navigate(`/match/${m.id}`)}
|
||||||
style={{
|
style={{ margin: 0, width: "100%", textAlign: "left" }}
|
||||||
width: "100%",
|
|
||||||
textAlign: "left",
|
|
||||||
background: "var(--surface-2)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "var(--radius-sm)",
|
|
||||||
padding: 12,
|
|
||||||
color: "var(--text)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div className="row" style={{ gap: 8 }}>
|
<div className="row" style={{ gap: 8 }}>
|
||||||
<b>{m.group_name}</b>
|
<b>{m.group_name}</b>
|
||||||
<span className="badge" style={{ color: "var(--accent-2)" }}>идёт</span>
|
<span className="badge badge-live">идёт</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="muted small">
|
<div className="muted small">
|
||||||
{formatTime(m.started_at)} · {m.player_count} игр.
|
{formatTime(m.started_at)} · {m.player_count}{" "}
|
||||||
|
{plural(m.player_count, "игрок", "игрока", "игроков")}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="small"
|
className="small"
|
||||||
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||||
>
|
>
|
||||||
|
{/* Ники — просто текст: карточка целиком ведёт в партию,
|
||||||
|
как и в списке партий группы. */}
|
||||||
{m.participants.map((p) => p.nickname).join(", ")}
|
{m.participants.map((p) => p.nickname).join(", ")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
import type { LeaderboardEntry } from "../domain/types";
|
import type { LeaderboardEntry } from "../domain/types";
|
||||||
import { Avatar } from "./Avatar";
|
import { Avatar } from "./Avatar";
|
||||||
|
import { PlayerLink } from "./PlayerLink";
|
||||||
|
|
||||||
function pct(v: number | null | undefined): string {
|
function pct(v: number | null | undefined): string {
|
||||||
return v == null ? "—" : `${Math.round(v * 100)}%`;
|
return v == null ? "—" : `${Math.round(v * 100)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Row({ entry }: { entry: LeaderboardEntry }) {
|
function Row({
|
||||||
return (
|
entry,
|
||||||
// Вся строка — ссылка на профиль. stopPropagation: на главной карточка-обёртка
|
provisional,
|
||||||
// ведёт в общую статистику, а клик по строке игрока — в его профиль.
|
profileLinks,
|
||||||
<Link
|
}: {
|
||||||
to={`/u/${entry.user_id}`}
|
entry: LeaderboardEntry;
|
||||||
className="lb-row"
|
provisional?: boolean;
|
||||||
style={{ color: "inherit" }}
|
profileLinks: boolean;
|
||||||
onClick={(e) => e.stopPropagation()}
|
}) {
|
||||||
>
|
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>
|
||||||
@@ -29,8 +29,16 @@ function Row({ entry }: { entry: LeaderboardEntry }) {
|
|||||||
<span>WR: {pct(entry.win_rate)}</span>
|
<span>WR: {pct(entry.win_rate)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="lb-score">{entry.score ?? "—"}</div>
|
<div className={"lb-score" + (provisional ? " provisional" : "")}>{entry.score ?? "—"}</div>
|
||||||
</Link>
|
</>
|
||||||
|
);
|
||||||
|
// Строка-ссылка на профиль — только там, где сама карточка никуда не ведёт
|
||||||
|
// (на главной карточка топа целиком открывает общую статистику).
|
||||||
|
if (!profileLinks) return <div className="lb-row">{content}</div>;
|
||||||
|
return (
|
||||||
|
<PlayerLink userId={entry.user_id} className="lb-row">
|
||||||
|
{content}
|
||||||
|
</PlayerLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,20 +46,22 @@ 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;
|
||||||
if (empty) {
|
if (empty) {
|
||||||
return <div className="muted small">Пока нет сыгранных партий.</div>;
|
return <div className="muted small">Сыгранных партий пока нет.</div>;
|
||||||
}
|
}
|
||||||
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 && (
|
||||||
<>
|
<>
|
||||||
@@ -59,7 +69,7 @@ export function Leaderboard({
|
|||||||
Новички (мало игр)
|
Новички (мало игр)
|
||||||
</div>
|
</div>
|
||||||
{provisional.map((e) => (
|
{provisional.map((e) => (
|
||||||
<Row key={e.user_id} entry={e} />
|
<Row key={e.user_id} entry={e} provisional profileLinks={profileLinks} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -69,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} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { formatDuration } from "../domain/format";
|
import { formatDate, formatDuration } from "../domain/format";
|
||||||
import { winReasonLabel } from "../domain/winReasons";
|
import { winReasonLabel } from "../domain/winReasons";
|
||||||
import type { MatchListItem } from "../domain/types";
|
import type { MatchListItem } from "../domain/types";
|
||||||
|
|
||||||
@@ -21,9 +21,9 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
|
|||||||
onClick={() => navigate(`/match/${m.id}`)}
|
onClick={() => navigate(`/match/${m.id}`)}
|
||||||
>
|
>
|
||||||
<div className="row-between">
|
<div className="row-between">
|
||||||
<span className="muted small">{m.played_at}</span>
|
<span className="muted small">{formatDate(m.played_at)}</span>
|
||||||
{inProgress ? (
|
{inProgress ? (
|
||||||
<span className="badge" style={{ color: "var(--accent-2)" }}>идёт</span>
|
<span className="badge badge-live">идёт</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="muted small">
|
<span className="muted small">
|
||||||
{formatDuration(m.duration_minutes)} · {winReasonLabel(m.win_reason)}
|
{formatDuration(m.duration_minutes)} · {winReasonLabel(m.win_reason)}
|
||||||
@@ -31,6 +31,8 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="stack" style={{ marginTop: 6, gap: 4 }}>
|
<div className="stack" style={{ marginTop: 6, gap: 4 }}>
|
||||||
|
{/* Ники здесь — просто текст: карточка целиком ведёт в партию,
|
||||||
|
вложенные ссылки на профили мешали бы выбору партии. */}
|
||||||
{sorted.map((p) => (
|
{sorted.map((p) => (
|
||||||
<div key={p.user_id} className="row" style={{ gap: 8 }}>
|
<div key={p.user_id} className="row" style={{ gap: 8 }}>
|
||||||
<span
|
<span
|
||||||
@@ -44,6 +46,7 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
|
|||||||
</span>
|
</span>
|
||||||
<span>{p.nickname}</span>
|
<span>{p.nickname}</span>
|
||||||
<span className="muted small">· {p.faction_name}</span>
|
<span className="muted small">· {p.faction_name}</span>
|
||||||
|
{p.was_random && <span className="badge">🎲</span>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function NotificationPanel({
|
|||||||
<div className="notif-panel">
|
<div className="notif-panel">
|
||||||
<div className="notif-panel-head">Уведомления</div>
|
<div className="notif-panel-head">Уведомления</div>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="notif-empty muted small">Уведомлений нет</div>
|
<div className="notif-empty muted small">Уведомлений пока нет.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="notif-list">
|
<div className="notif-list">
|
||||||
{items.map((n) => (
|
{items.map((n) => (
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ссылка на профиль игрока (/u/:id). Оборачивает всё поле с ником (строку с аватаром,
|
||||||
|
* местом, фракцией…), а не голый текст. stopPropagation — строки часто лежат внутри
|
||||||
|
* кликабельных карточек (список партий, топ на главной), и клик по игроку должен
|
||||||
|
* вести в профиль, а не срабатывать навигацией карточки.
|
||||||
|
*/
|
||||||
|
export function PlayerLink({
|
||||||
|
userId,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
userId: number;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={`/u/${userId}`}
|
||||||
|
className={className}
|
||||||
|
style={{ color: "inherit", ...style }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ export function ProfileStatsCard({
|
|||||||
<b>{o.wins}</b>
|
<b>{o.wins}</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="row-between">
|
<div className="row-between">
|
||||||
<div>Winrate</div>
|
<div>Винрейт</div>
|
||||||
<b>{pct(o.win_rate)}</b>
|
<b>{pct(o.win_rate)}</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="row-between">
|
<div className="row-between">
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Тумблер вкл/выкл (стили .switch в global.css) — единый контрол для булевых опций
|
||||||
|
* (дополнения группы, фильтры, флаги). Вместо нативных чекбоксов.
|
||||||
|
*/
|
||||||
|
export function Switch({
|
||||||
|
checked,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange?: (checked: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onChange?.(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="switch-track">
|
||||||
|
<span className="switch-thumb" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,15 @@ function shifted(iso: string): Date {
|
|||||||
return new Date(new Date(iso).getTime() + APP_TZ_OFFSET_MIN * 60_000);
|
return new Date(new Date(iso).getTime() + APP_TZ_OFFSET_MIN * 60_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Склонение существительного при числе: plural(3, "игрок", "игрока", "игроков").
|
||||||
|
export function plural(n: number, one: string, few: string, many: string): string {
|
||||||
|
const m10 = n % 10;
|
||||||
|
const m100 = n % 100;
|
||||||
|
if (m10 === 1 && m100 !== 11) return one;
|
||||||
|
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few;
|
||||||
|
return many;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDuration(minutes: number | null | undefined): string {
|
export function formatDuration(minutes: number | null | undefined): string {
|
||||||
if (minutes == null) return "—";
|
if (minutes == null) return "—";
|
||||||
const h = Math.floor(minutes / 60);
|
const h = Math.floor(minutes / 60);
|
||||||
@@ -28,6 +37,14 @@ export function formatTime(iso: string | null | undefined): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// «Дата игры» приходит date-only строкой (YYYY-MM-DD) — просто переставляем части,
|
||||||
|
// без сдвига пояса (бэкенд уже посчитал дату в поясе приложения).
|
||||||
|
export function formatDate(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
|
||||||
|
return m ? `${m[3]}.${m[2]}.${m[1]}` : iso;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDateTime(iso: string | null | undefined): string {
|
export function formatDateTime(iso: string | null | undefined): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -75,6 +75,26 @@ export function useCreateGroup() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useRenameGroup(groupId: number) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (name: string) =>
|
||||||
|
unwrap(
|
||||||
|
await api.PATCH("/api/groups/{group_id}", {
|
||||||
|
params: { path: { group_id: groupId } },
|
||||||
|
body: { name },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
// Имя группы видно в шапке группы, переключателе групп (me), на главной.
|
||||||
|
qc.invalidateQueries({ queryKey: qk.group(groupId) });
|
||||||
|
qc.invalidateQueries({ queryKey: qk.groups });
|
||||||
|
qc.invalidateQueries({ queryKey: qk.me });
|
||||||
|
qc.invalidateQueries({ queryKey: qk.home });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useSetGroupExpansions(groupId: number) {
|
export function useSetGroupExpansions(groupId: number) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -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 } } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -145,7 +145,7 @@ export function AccountPage() {
|
|||||||
<div className="row">
|
<div className="row">
|
||||||
<input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={me.nickname} />
|
<input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={me.nickname} />
|
||||||
<button className="btn btn-primary" onClick={saveNick} disabled={updateNick.isPending}>
|
<button className="btn btn-primary" onClick={saveNick} disabled={updateNick.isPending}>
|
||||||
ОК
|
Сохранить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,7 +153,7 @@ export function AccountPage() {
|
|||||||
|
|
||||||
{stats && <ProfileStatsCard stats={stats} />}
|
{stats && <ProfileStatsCard stats={stats} />}
|
||||||
|
|
||||||
<button className="btn btn-danger" style={{ width: "100%" }} onClick={doLogout}>
|
<button className="btn btn-danger btn-block" onClick={doLogout}>
|
||||||
Выйти
|
Выйти
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export function CreateMatchPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
const filled = rows.filter((r) => r.user_id !== "" && r.faction_id !== "");
|
const filled = rows.filter((r) => r.user_id !== "" && r.faction_id !== "");
|
||||||
if (filled.length < 2) {
|
if (filled.length < 2) {
|
||||||
setError("Нужно минимум 2 участника с выбранным игроком и фракцией.");
|
setError("Нужно минимум 2 игрока с выбранной фракцией.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (filled.length > MAX_MATCH_PLAYERS) {
|
if (filled.length > MAX_MATCH_PLAYERS) {
|
||||||
@@ -236,8 +236,8 @@ export function CreateMatchPage() {
|
|||||||
{error && <p className="error-text">{error}</p>}
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary btn-block"
|
||||||
style={{ width: "100%", marginTop: 12 }}
|
style={{ marginTop: 12 }}
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
disabled={startMatch.isPending}
|
disabled={startMatch.isPending}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,13 +1,27 @@
|
|||||||
|
import { UserPlus } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { ApiError } from "../api/client";
|
||||||
import { CreateGroupForm } from "../components/CreateGroupForm";
|
import { CreateGroupForm } from "../components/CreateGroupForm";
|
||||||
import { GroupInvitations } from "../components/GroupInvitations";
|
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 { formatDate } from "../domain/format";
|
||||||
|
import { useToast } from "../context/ToastContext";
|
||||||
import { useMe } from "../hooks/auth";
|
import { useMe } from "../hooks/auth";
|
||||||
import { useGroup, useGroupMatches, useGroupStats } from "../hooks/groups";
|
import {
|
||||||
|
useGroup,
|
||||||
|
useGroupMatches,
|
||||||
|
useGroupMembers,
|
||||||
|
useGroupStats,
|
||||||
|
useInviteMember,
|
||||||
|
} from "../hooks/groups";
|
||||||
|
import { useUserSearch } from "../hooks/users";
|
||||||
|
|
||||||
export function GroupPage() {
|
export function GroupPage() {
|
||||||
const { data: me, isLoading } = useMe();
|
const { data: me, isLoading } = useMe();
|
||||||
@@ -17,9 +31,37 @@ export function GroupPage() {
|
|||||||
const { data: group } = useGroup(groupId);
|
const { data: group } = useGroup(groupId);
|
||||||
const { data: stats } = useGroupStats(groupId);
|
const { data: stats } = useGroupStats(groupId);
|
||||||
const { data: matches } = useGroupMatches(groupId);
|
const { data: matches } = useGroupMatches(groupId);
|
||||||
|
const { data: members } = useGroupMembers(groupId);
|
||||||
|
const inviteMember = useInviteMember();
|
||||||
|
const toast = useToast();
|
||||||
|
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 />;
|
||||||
|
|
||||||
|
const invite = async () => {
|
||||||
|
const nick = newNick.trim();
|
||||||
|
if (!nick || !groupId) return;
|
||||||
|
try {
|
||||||
|
await inviteMember.mutateAsync({ groupId, nickname: nick });
|
||||||
|
setNewNick("");
|
||||||
|
toast.show("Приглашение отправлено");
|
||||||
|
} catch (e) {
|
||||||
|
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Подсказки: уже состоящих в группе не предлагаем, показываем до 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>
|
||||||
@@ -49,14 +91,14 @@ export function GroupPage() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="muted small">
|
<p className="muted small">
|
||||||
Партий: {stats?.total_matches ?? 0}
|
Партий: {stats?.total_matches ?? 0}
|
||||||
{stats?.last_match_at ? ` · последняя: ${stats.last_match_at}` : ""}
|
{stats?.last_match_at ? ` · последняя: ${formatDate(stats.last_match_at)}` : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GroupInvitations />
|
<GroupInvitations />
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Статистика игроков</h3>
|
<h3>Список игроков</h3>
|
||||||
{stats && (
|
{stats && (
|
||||||
<Leaderboard
|
<Leaderboard
|
||||||
entries={stats.leaderboard}
|
entries={stats.leaderboard}
|
||||||
@@ -64,6 +106,43 @@ export function GroupPage() {
|
|||||||
inactive={stats.inactive}
|
inactive={stats.inactive}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Приглашение нового игрока — здесь, рядом со списком (не в настройках). */}
|
||||||
|
{(members ?? []).length >= MAX_GROUP_SIZE ? (
|
||||||
|
<p className="muted small" style={{ margin: "10px 0 0" }}>
|
||||||
|
Достигнут максимум участников ({MAX_GROUP_SIZE}).
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="row" style={{ marginTop: 10 }}>
|
||||||
|
<div className="suggest-wrap">
|
||||||
|
<input
|
||||||
|
placeholder="Ник игрока — отправить приглашение"
|
||||||
|
value={newNick}
|
||||||
|
onChange={(e) => {
|
||||||
|
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}>
|
||||||
|
<UserPlus size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GroupSwitcher />
|
<GroupSwitcher />
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import { Trash2, UserPlus } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
|
import { PlayerLink } from "../components/PlayerLink";
|
||||||
import { Spinner } from "../components/Spinner";
|
import { Spinner } from "../components/Spinner";
|
||||||
|
import { Switch } from "../components/Switch";
|
||||||
import { MAX_GROUP_SIZE } from "../domain/constants";
|
import { MAX_GROUP_SIZE } from "../domain/constants";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
import { useMe } from "../hooks/auth";
|
import { useMe } from "../hooks/auth";
|
||||||
import { useGroup, useGroupMembers, useInviteMember, useRemoveMember, useSetGroupExpansions } from "../hooks/groups";
|
import {
|
||||||
|
useGroup,
|
||||||
|
useGroupMembers,
|
||||||
|
useRemoveMember,
|
||||||
|
useRenameGroup,
|
||||||
|
useSetGroupExpansions,
|
||||||
|
} from "../hooks/groups";
|
||||||
import { useExpansions } from "../hooks/reference";
|
import { useExpansions } from "../hooks/reference";
|
||||||
|
|
||||||
export function GroupSettingsPage() {
|
export function GroupSettingsPage() {
|
||||||
@@ -16,12 +25,14 @@ export function GroupSettingsPage() {
|
|||||||
const { data: expansions } = useExpansions();
|
const { data: expansions } = useExpansions();
|
||||||
const { data: members } = useGroupMembers(groupId);
|
const { data: members } = useGroupMembers(groupId);
|
||||||
const setExpansions = useSetGroupExpansions(groupId ?? 0);
|
const setExpansions = useSetGroupExpansions(groupId ?? 0);
|
||||||
const inviteMember = useInviteMember();
|
const renameGroup = useRenameGroup(groupId ?? 0);
|
||||||
const removeMember = useRemoveMember(groupId ?? 0);
|
const removeMember = useRemoveMember(groupId ?? 0);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
const [newNick, setNewNick] = useState("");
|
// Название редактируется локально; до первого ввода берём значение из группы.
|
||||||
|
const [name, setName] = useState<string | null>(null);
|
||||||
|
const [removeTarget, setRemoveTarget] = useState<{ userId: number; nickname: string } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (group) setSelected(new Set(group.expansion_ids));
|
if (group) setSelected(new Set(group.expansion_ids));
|
||||||
@@ -38,22 +49,24 @@ export function GroupSettingsPage() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveExpansions = async () => {
|
const nameValue = name ?? group.name;
|
||||||
|
|
||||||
|
const saveName = async () => {
|
||||||
|
const trimmed = nameValue.trim();
|
||||||
|
if (!trimmed || trimmed === group.name) return;
|
||||||
try {
|
try {
|
||||||
await setExpansions.mutateAsync([...selected]);
|
await renameGroup.mutateAsync(trimmed);
|
||||||
toast.show("Дополнения сохранены");
|
setName(null);
|
||||||
|
toast.show("Название сохранено");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const add = async () => {
|
const saveExpansions = async () => {
|
||||||
const nick = newNick.trim();
|
|
||||||
if (!nick) return;
|
|
||||||
try {
|
try {
|
||||||
await inviteMember.mutateAsync({ groupId: group.id, nickname: nick });
|
await setExpansions.mutateAsync([...selected]);
|
||||||
setNewNick("");
|
toast.show("Дополнения сохранены");
|
||||||
toast.show("Приглашение отправлено");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
@@ -69,27 +82,43 @@ export function GroupSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<div className="card">
|
||||||
|
<h3>Название</h3>
|
||||||
|
<div className="row">
|
||||||
|
<input
|
||||||
|
value={nameValue}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") saveName();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={saveName}
|
||||||
|
disabled={renameGroup.isPending || !nameValue.trim() || nameValue.trim() === group.name}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Дополнения</h3>
|
<h3>Дополнения</h3>
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
{(expansions ?? [])
|
{/* Базовые дополнения показываем, как и при создании группы: включены и заблокированы. */}
|
||||||
.filter((e) => !e.is_base)
|
{(expansions ?? []).map((e) => (
|
||||||
.map((e) => (
|
<label key={e.id} className="row" style={{ justifyContent: "space-between" }}>
|
||||||
<label key={e.id} className="row" style={{ justifyContent: "space-between" }}>
|
<span>
|
||||||
<span>{e.name_ru}</span>
|
{e.name_ru}
|
||||||
<span className="switch">
|
{e.is_base && <span className="muted small"> (всегда включено)</span>}
|
||||||
<input
|
</span>
|
||||||
type="checkbox"
|
<Switch
|
||||||
disabled={!isOwner}
|
disabled={e.is_base || !isOwner}
|
||||||
checked={selected.has(e.id)}
|
checked={e.is_base || selected.has(e.id)}
|
||||||
onChange={() => toggle(e.id)}
|
onChange={() => toggle(e.id)}
|
||||||
/>
|
/>
|
||||||
<span className="switch-track">
|
</label>
|
||||||
<span className="switch-thumb" />
|
))}
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
<button className="btn btn-primary" style={{ marginTop: 10 }} onClick={saveExpansions}>
|
<button className="btn btn-primary" style={{ marginTop: 10 }} onClick={saveExpansions}>
|
||||||
@@ -99,30 +128,21 @@ export function GroupSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
{/* Приглашение нового игрока — на странице группы («Список игроков»). */}
|
||||||
<h3>Участники ({(members ?? []).length}/{MAX_GROUP_SIZE})</h3>
|
<h3>Участники ({(members ?? []).length}/{MAX_GROUP_SIZE})</h3>
|
||||||
{isOwner && (members ?? []).length >= MAX_GROUP_SIZE && (
|
|
||||||
<p className="muted small">Достигнут максимум участников ({MAX_GROUP_SIZE}).</p>
|
|
||||||
)}
|
|
||||||
{isOwner && (members ?? []).length < MAX_GROUP_SIZE && (
|
|
||||||
<div className="row" style={{ marginBottom: 10 }}>
|
|
||||||
<input
|
|
||||||
placeholder="Ник игрока — отправить приглашение"
|
|
||||||
value={newNick}
|
|
||||||
onChange={(e) => setNewNick(e.target.value)}
|
|
||||||
/>
|
|
||||||
<button className="btn btn-primary" onClick={add} disabled={inviteMember.isPending}>
|
|
||||||
<UserPlus size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
{(members ?? []).map((m) => (
|
{(members ?? []).map((m) => (
|
||||||
<div key={m.user_id} className="row-between">
|
<div key={m.user_id} className="row-between">
|
||||||
<span>
|
{/* Кликабельна вся левая часть строки, не только текст ника. */}
|
||||||
{m.nickname} <span className="muted small">· {m.role}</span>
|
<PlayerLink userId={m.user_id} style={{ flex: 1 }}>
|
||||||
</span>
|
{m.nickname}{" "}
|
||||||
|
<span className="muted small">· {m.role === "owner" ? "владелец" : "участник"}</span>
|
||||||
|
</PlayerLink>
|
||||||
{isOwner && m.role !== "owner" && (
|
{isOwner && m.role !== "owner" && (
|
||||||
<button className="btn btn-ghost btn-danger small" onClick={() => remove(m.user_id)}>
|
<button
|
||||||
|
className="btn btn-ghost btn-danger small"
|
||||||
|
onClick={() => setRemoveTarget({ userId: m.user_id, nickname: m.nickname })}
|
||||||
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -130,6 +150,21 @@ export function GroupSettingsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={removeTarget != null}
|
||||||
|
danger
|
||||||
|
title={`Удалить участника «${removeTarget?.nickname}»?`}
|
||||||
|
message="Игрок будет удалён из группы. Его сыгранные партии сохранятся."
|
||||||
|
confirmLabel="Удалить"
|
||||||
|
onCancel={() => setRemoveTarget(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (!removeTarget) return;
|
||||||
|
const userId = removeTarget.userId;
|
||||||
|
setRemoveTarget(null);
|
||||||
|
remove(userId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
export function HelpPage() {
|
export function HelpPage() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{/* Название страницы уже в верхней панели — в теле только пояснение. */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h2 style={{ margin: 0 }}>Справка</h2>
|
<p className="muted small" style={{ margin: 0 }}>
|
||||||
<p className="muted small" style={{ marginTop: 6 }}>
|
|
||||||
Здесь со временем появятся правила, подсказки и пояснения. Пока — о том, как
|
Здесь со временем появятся правила, подсказки и пояснения. Пока — о том, как
|
||||||
считается рейтинг игроков.
|
считается рейтинг игроков.
|
||||||
</p>
|
</p>
|
||||||
@@ -55,7 +55,7 @@ export function HelpPage() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Попадание в топ</h3>
|
<h3>Попадание в топ</h3>
|
||||||
<p className="small">
|
<p className="small">
|
||||||
Чтобы попасть в ранжированный топ, нужно сыграть минимум <b>3 партии</b>. До этого
|
Чтобы попасть в ранжированный топ, нужно сыграть минимум <b>5 партий</b>. До этого
|
||||||
игрок показывается в блоке «Новички» — предварительно, без места в рейтинге.
|
игрок показывается в блоке «Новички» — предварительно, без места в рейтинге.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,7 +63,7 @@ export function HelpPage() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Другие показатели</h3>
|
<h3>Другие показатели</h3>
|
||||||
<ul className="small" style={{ margin: 0, paddingLeft: 18 }}>
|
<ul className="small" style={{ margin: 0, paddingLeft: 18 }}>
|
||||||
<li><b>Винрейт</b> — доля партий, в которых игрок занял 1-е место.</li>
|
<li><b>Винрейт (WR)</b> — доля партий, в которых игрок занял 1-е место.</li>
|
||||||
<li><b>Среднее место</b> — среднее по завершённым партиям.</li>
|
<li><b>Среднее место</b> — среднее по завершённым партиям.</li>
|
||||||
<li>
|
<li>
|
||||||
При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту
|
При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ function TelegramNicknamePicker({
|
|||||||
</div>
|
</div>
|
||||||
{error && <p className="error-text">{error}</p>}
|
{error && <p className="error-text">{error}</p>}
|
||||||
<button className="btn btn-ghost small" onClick={onCancel}>
|
<button className="btn btn-ghost small" onClick={onCancel}>
|
||||||
Назад
|
Отмена
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import { useNavigate, useParams } from "react-router-dom";
|
|||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
import { MatchMedia } from "../components/MatchMedia";
|
import { MatchMedia } from "../components/MatchMedia";
|
||||||
|
import { PlayerLink } from "../components/PlayerLink";
|
||||||
import { Spinner } from "../components/Spinner";
|
import { Spinner } from "../components/Spinner";
|
||||||
import { formatDuration, formatTime } from "../domain/format";
|
import { formatDate, formatDuration, formatTime } from "../domain/format";
|
||||||
import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
|
import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
import {
|
import {
|
||||||
@@ -119,9 +120,9 @@ export function MatchDetailPage() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="row-between">
|
<div className="row-between">
|
||||||
<h2 style={{ margin: 0 }}>{match.played_at}</h2>
|
<h2 style={{ margin: 0 }}>{formatDate(match.played_at)}</h2>
|
||||||
{inProgress ? (
|
{inProgress ? (
|
||||||
<span className="badge" style={{ color: "var(--accent-2)" }}>идёт</span>
|
<span className="badge badge-live">идёт</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="muted small">{formatDuration(match.duration_minutes)}</span>
|
<span className="muted small">{formatDuration(match.duration_minutes)}</span>
|
||||||
)}
|
)}
|
||||||
@@ -145,7 +146,7 @@ export function MatchDetailPage() {
|
|||||||
<div className="stack">
|
<div className="stack">
|
||||||
{sorted.map((p) => (
|
{sorted.map((p) => (
|
||||||
<div key={p.user_id}>
|
<div key={p.user_id}>
|
||||||
<div className="row" style={{ gap: 8 }}>
|
<PlayerLink userId={p.user_id} className="row" style={{ gap: 8 }}>
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
"place-badge" +
|
"place-badge" +
|
||||||
@@ -158,7 +159,7 @@ export function MatchDetailPage() {
|
|||||||
<b>{p.nickname}</b>
|
<b>{p.nickname}</b>
|
||||||
<span className="muted small">· {p.faction_name}</span>
|
<span className="muted small">· {p.faction_name}</span>
|
||||||
{p.was_random && <span className="badge">🎲</span>}
|
{p.was_random && <span className="badge">🎲</span>}
|
||||||
</div>
|
</PlayerLink>
|
||||||
{p.comment && <div className="small muted" style={{ marginLeft: 32 }}>{p.comment}</div>}
|
{p.comment && <div className="small muted" style={{ marginLeft: 32 }}>{p.comment}</div>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -181,11 +182,11 @@ export function MatchDetailPage() {
|
|||||||
<h3>Состав</h3>
|
<h3>Состав</h3>
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
{match.participants.map((p) => (
|
{match.participants.map((p) => (
|
||||||
<div key={p.user_id} className="row" style={{ gap: 8 }}>
|
<PlayerLink key={p.user_id} userId={p.user_id} className="row" style={{ gap: 8 }}>
|
||||||
<b>{p.nickname}</b>
|
<b>{p.nickname}</b>
|
||||||
<span className="muted small">· {p.faction_name}</span>
|
<span className="muted small">· {p.faction_name}</span>
|
||||||
{p.was_random && <span className="badge">🎲</span>}
|
{p.was_random && <span className="badge">🎲</span>}
|
||||||
</div>
|
</PlayerLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -260,8 +261,7 @@ export function MatchDetailPage() {
|
|||||||
{error && <p className="error-text">{error}</p>}
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary btn-block"
|
||||||
style={{ width: "100%" }}
|
|
||||||
onClick={submitFinish}
|
onClick={submitFinish}
|
||||||
disabled={finish.isPending}
|
disabled={finish.isPending}
|
||||||
>
|
>
|
||||||
@@ -273,11 +273,7 @@ export function MatchDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{canModify && (
|
{canModify && (
|
||||||
<button
|
<button className="btn btn-danger btn-block" onClick={() => setConfirmRemove(true)}>
|
||||||
className="btn btn-danger"
|
|
||||||
style={{ width: "100%" }}
|
|
||||||
onClick={() => setConfirmRemove(true)}
|
|
||||||
>
|
|
||||||
{inProgress ? "Отменить партию" : "Удалить партию"}
|
{inProgress ? "Отменить партию" : "Удалить партию"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -292,7 +288,6 @@ export function MatchDetailPage() {
|
|||||||
: "Сыгранная партия и её результаты будут удалены. Действие необратимо."
|
: "Сыгранная партия и её результаты будут удалены. Действие необратимо."
|
||||||
}
|
}
|
||||||
confirmLabel={inProgress ? "Отменить партию" : "Удалить"}
|
confirmLabel={inProgress ? "Отменить партию" : "Удалить"}
|
||||||
cancelLabel="Назад"
|
|
||||||
onCancel={() => setConfirmRemove(false)}
|
onCancel={() => setConfirmRemove(false)}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
setConfirmRemove(false);
|
setConfirmRemove(false);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
import { Avatar } from "../components/Avatar";
|
import { Avatar } from "../components/Avatar";
|
||||||
|
import { PlayerLink } from "../components/PlayerLink";
|
||||||
import { Spinner } from "../components/Spinner";
|
import { Spinner } from "../components/Spinner";
|
||||||
import type { LeaderboardEntry } from "../domain/types";
|
import type { LeaderboardEntry } from "../domain/types";
|
||||||
import { useLeaderboard } from "../hooks/stats";
|
import { useLeaderboard } from "../hooks/stats";
|
||||||
@@ -42,8 +42,8 @@ export function OverallStatsPage() {
|
|||||||
const provisional = filterSort(data.provisional);
|
const provisional = filterSort(data.provisional);
|
||||||
const arrow = (k: SortKey) => (k === sortKey ? (dir === 1 ? " ↑" : " ↓") : "");
|
const arrow = (k: SortKey) => (k === sortKey ? (dir === 1 ? " ↑" : " ↓") : "");
|
||||||
|
|
||||||
const row = (e: LeaderboardEntry) => (
|
const row = (e: LeaderboardEntry, isProvisional = false) => (
|
||||||
<Link key={e.user_id} to={`/u/${e.user_id}`} className="st-row" style={{ color: "inherit" }}>
|
<PlayerLink key={e.user_id} userId={e.user_id} className="st-row">
|
||||||
<span className={"st-num" + (e.rank && e.rank <= 3 ? " gold" : "")}>{e.rank ?? "—"}</span>
|
<span className={"st-num" + (e.rank && e.rank <= 3 ? " gold" : "")}>{e.rank ?? "—"}</span>
|
||||||
<span className="st-player">
|
<span className="st-player">
|
||||||
<Avatar url={e.avatar_url} nickname={e.nickname} size={28} />
|
<Avatar url={e.avatar_url} nickname={e.nickname} size={28} />
|
||||||
@@ -52,14 +52,16 @@ export function OverallStatsPage() {
|
|||||||
<span className="st-num">{e.games}</span>
|
<span className="st-num">{e.games}</span>
|
||||||
<span className="st-num">{e.wins}</span>
|
<span className="st-num">{e.wins}</span>
|
||||||
<span className="st-num">{pct(e.win_rate)}</span>
|
<span className="st-num">{pct(e.win_rate)}</span>
|
||||||
<span className="st-num lb-score">{e.score ?? "—"}</span>
|
<span className={"st-num lb-score" + (isProvisional ? " provisional" : "")}>
|
||||||
</Link>
|
{e.score ?? "—"}
|
||||||
|
</span>
|
||||||
|
</PlayerLink>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{/* Название страницы уже в верхней панели — в теле сразу поиск. */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Общая статистика</h3>
|
|
||||||
<input placeholder="Поиск по нику…" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input placeholder="Поиск по нику…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,14 +79,14 @@ export function OverallStatsPage() {
|
|||||||
Никого не найдено.
|
Никого не найдено.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
entries.map(row)
|
entries.map((e) => row(e))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{provisional.length > 0 && (
|
{provisional.length > 0 && (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="label">Новички (меньше {data.min_games} игр)</div>
|
<div className="label">Новички (меньше {data.min_games} игр)</div>
|
||||||
{provisional.map(row)}
|
{provisional.map((e) => row(e, true))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,8 +64,7 @@ export function PublicProfilePage() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
{groups.length === 1 ? (
|
{groups.length === 1 ? (
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary btn-block"
|
||||||
style={{ width: "100%" }}
|
|
||||||
onClick={() => doInvite(groups[0].id)}
|
onClick={() => doInvite(groups[0].id)}
|
||||||
disabled={invite.isPending}
|
disabled={invite.isPending}
|
||||||
>
|
>
|
||||||
@@ -74,8 +73,7 @@ export function PublicProfilePage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary btn-block"
|
||||||
style={{ width: "100%" }}
|
|
||||||
onClick={() => setPickOpen((v) => !v)}
|
onClick={() => setPickOpen((v) => !v)}
|
||||||
>
|
>
|
||||||
Добавить в группу
|
Добавить в группу
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function AdminAccountsPage() {
|
|||||||
if (editId == null || !name) return;
|
if (editId == null || !name) return;
|
||||||
try {
|
try {
|
||||||
await update.mutateAsync({ userId: editId, nickname: name });
|
await update.mutateAsync({ userId: editId, nickname: name });
|
||||||
toast.show("Имя обновлено");
|
toast.show("Ник обновлён");
|
||||||
setEditId(null);
|
setEditId(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Не удалось переименовать");
|
toast.show(e instanceof ApiError ? e.message : "Не удалось переименовать");
|
||||||
@@ -42,7 +42,7 @@ export function AdminAccountsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3>Аккаунты</h3>
|
{/* Название раздела уже в активной вкладке админки. */}
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
{(users ?? []).map((u) => (
|
{(users ?? []).map((u) => (
|
||||||
<div key={u.id} className="card" style={{ margin: 0 }}>
|
<div key={u.id} className="card" style={{ margin: 0 }}>
|
||||||
@@ -63,7 +63,7 @@ export function AdminAccountsPage() {
|
|||||||
disabled={update.isPending || !editName.trim() || editName.trim() === u.nickname}
|
disabled={update.isPending || !editName.trim() || editName.trim() === u.nickname}
|
||||||
onClick={saveName}
|
onClick={saveName}
|
||||||
>
|
>
|
||||||
ОК
|
Сохранить
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-ghost small" onClick={() => setEditId(null)}>
|
<button className="btn btn-ghost small" onClick={() => setEditId(null)}>
|
||||||
Отмена
|
Отмена
|
||||||
@@ -72,7 +72,7 @@ export function AdminAccountsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="row-between">
|
<div className="row-between">
|
||||||
<div>
|
<div>
|
||||||
<b>{u.nickname}</b> {u.role === "admin" && <span className="badge">admin</span>}
|
<b>{u.nickname}</b> {u.role === "admin" && <span className="badge">админ</span>}
|
||||||
<div className="muted small">
|
<div className="muted small">
|
||||||
#{u.id} · {u.auth_provider} · {u.is_active ? "активен" : "отключён"}
|
#{u.id} · {u.auth_provider} · {u.is_active ? "активен" : "отключён"}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useRef, useState } from "react";
|
|||||||
|
|
||||||
import { ApiError } from "../../api/client";
|
import { ApiError } from "../../api/client";
|
||||||
import { Avatar } from "../../components/Avatar";
|
import { Avatar } from "../../components/Avatar";
|
||||||
|
import { ConfirmDialog } from "../../components/ConfirmDialog";
|
||||||
import { Spinner } from "../../components/Spinner";
|
import { Spinner } from "../../components/Spinner";
|
||||||
import { useToast } from "../../context/ToastContext";
|
import { useToast } from "../../context/ToastContext";
|
||||||
import {
|
import {
|
||||||
@@ -87,6 +88,7 @@ function AchievementCard({ ach }: { ach: AchievementRead }) {
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||||
const [name, setName] = useState(ach.name);
|
const [name, setName] = useState(ach.name);
|
||||||
const [description, setDescription] = useState(ach.description);
|
const [description, setDescription] = useState(ach.description);
|
||||||
const [condition, setCondition] = useState(ach.condition);
|
const [condition, setCondition] = useState(ach.condition);
|
||||||
@@ -117,7 +119,6 @@ function AchievementCard({ ach }: { ach: AchievementRead }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
if (!window.confirm(`Удалить ачивку «${ach.name}»?`)) return;
|
|
||||||
try {
|
try {
|
||||||
await del.mutateAsync(ach.slug);
|
await del.mutateAsync(ach.slug);
|
||||||
toast.show("Удалено");
|
toast.show("Удалено");
|
||||||
@@ -175,7 +176,11 @@ function AchievementCard({ ach }: { ach: AchievementRead }) {
|
|||||||
<button className="btn small" onClick={() => setEditing(true)}>
|
<button className="btn small" onClick={() => setEditing(true)}>
|
||||||
Изменить
|
Изменить
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-ghost btn-danger small" onClick={remove} disabled={del.isPending}>
|
<button
|
||||||
|
className="btn btn-ghost btn-danger small"
|
||||||
|
onClick={() => setConfirmRemove(true)}
|
||||||
|
disabled={del.isPending}
|
||||||
|
>
|
||||||
Удалить
|
Удалить
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
@@ -187,6 +192,19 @@ function AchievementCard({ ach }: { ach: AchievementRead }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmRemove}
|
||||||
|
danger
|
||||||
|
title={`Удалить ачивку «${ach.name}»?`}
|
||||||
|
message="Ачивка и её иконка будут удалены. Действие необратимо."
|
||||||
|
confirmLabel="Удалить"
|
||||||
|
onCancel={() => setConfirmRemove(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setConfirmRemove(false);
|
||||||
|
remove();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export function AdminFactionsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3>Фракции</h3>
|
{/* Название раздела уже в активной вкладке админки. */}
|
||||||
<p className="muted small">
|
<p className="muted small" style={{ marginTop: 0 }}>
|
||||||
Переименование меняет название фракции во всех партиях и статистике.
|
Переименование меняет название фракции во всех партиях и статистике.
|
||||||
</p>
|
</p>
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
@@ -48,7 +48,7 @@ export function AdminFactionsPage() {
|
|||||||
disabled={!names[f.id] || names[f.id] === f.name_ru}
|
disabled={!names[f.id] || names[f.id] === f.name_ru}
|
||||||
onClick={() => save(f.id)}
|
onClick={() => save(f.id)}
|
||||||
>
|
>
|
||||||
ОК
|
Сохранить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function AdminLoginPage() {
|
|||||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="error-text">{error}</p>}
|
{error && <p className="error-text">{error}</p>}
|
||||||
<button className="btn btn-primary" style={{ width: "100%" }} onClick={submit} disabled={login.isPending}>
|
<button className="btn btn-primary btn-block" onClick={submit} disabled={login.isPending}>
|
||||||
Войти
|
Войти
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function AdminLogsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3>Журнал событий</h3>
|
{/* Название раздела уже в активной вкладке админки. */}
|
||||||
<div className="row" style={{ marginBottom: 10, gap: 8 }}>
|
<div className="row" style={{ marginBottom: 10, gap: 8 }}>
|
||||||
<select style={{ width: "auto" }} value={action} onChange={(e) => setAction(e.target.value)}>
|
<select style={{ width: "auto" }} value={action} onChange={(e) => setAction(e.target.value)}>
|
||||||
{ACTIONS.map((a) => (
|
{ACTIONS.map((a) => (
|
||||||
@@ -59,7 +59,7 @@ export function AdminLogsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{(data?.items ?? []).length === 0 && <div className="muted small">Событий нет.</div>}
|
{(data?.items ?? []).length === 0 && <div className="muted small">Событий пока нет.</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { ApiError } from "../../api/client";
|
import { ApiError } from "../../api/client";
|
||||||
import { MatchMedia } from "../../components/MatchMedia";
|
import { MatchMedia } from "../../components/MatchMedia";
|
||||||
import { Spinner } from "../../components/Spinner";
|
import { Spinner } from "../../components/Spinner";
|
||||||
|
import { Switch } from "../../components/Switch";
|
||||||
import { WIN_REASONS, type WinReason } from "../../domain/winReasons";
|
import { WIN_REASONS, type WinReason } from "../../domain/winReasons";
|
||||||
import { useToast } from "../../context/ToastContext";
|
import { useToast } from "../../context/ToastContext";
|
||||||
import {
|
import {
|
||||||
@@ -141,12 +142,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label className="row small" style={{ flex: "0 0 auto", gap: 4 }}>
|
<label className="row small" style={{ flex: "0 0 auto", gap: 4 }}>
|
||||||
<input
|
<Switch checked={r.eliminated} onChange={(v) => upd(i, { eliminated: v })} />
|
||||||
type="checkbox"
|
|
||||||
style={{ width: "auto" }}
|
|
||||||
checked={r.eliminated}
|
|
||||||
onChange={(e) => upd(i, { eliminated: e.target.checked })}
|
|
||||||
/>
|
|
||||||
выбыл
|
выбыл
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -190,7 +186,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
|||||||
|
|
||||||
{error && <p className="error-text">{error}</p>}
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
|
||||||
<button className="btn btn-primary" style={{ width: "100%" }} onClick={save} disabled={update.isPending}>
|
<button className="btn btn-primary btn-block" onClick={save} disabled={update.isPending}>
|
||||||
Сохранить изменения
|
Сохранить изменения
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Pencil, Trash2 } from "lucide-react";
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { ApiError } from "../../api/client";
|
||||||
|
import { ConfirmDialog } from "../../components/ConfirmDialog";
|
||||||
|
import { formatDate, plural } from "../../domain/format";
|
||||||
import { Spinner } from "../../components/Spinner";
|
import { Spinner } from "../../components/Spinner";
|
||||||
|
import { Switch } from "../../components/Switch";
|
||||||
import { useToast } from "../../context/ToastContext";
|
import { useToast } from "../../context/ToastContext";
|
||||||
import {
|
import {
|
||||||
useAdminDeleteMatch,
|
useAdminDeleteMatch,
|
||||||
@@ -19,6 +23,7 @@ export function AdminRecordsPage() {
|
|||||||
const [factionFilter, setFactionFilter] = useState<number | null>(null);
|
const [factionFilter, setFactionFilter] = useState<number | null>(null);
|
||||||
const [grouped, setGrouped] = useState(true);
|
const [grouped, setGrouped] = useState(true);
|
||||||
const [editId, setEditId] = useState<number | null>(null);
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<AdminMatchRead | null>(null);
|
||||||
|
|
||||||
const { data: groups } = useAdminGroups();
|
const { data: groups } = useAdminGroups();
|
||||||
const { data: users } = useAdminUsers();
|
const { data: users } = useAdminUsers();
|
||||||
@@ -50,23 +55,18 @@ export function AdminRecordsPage() {
|
|||||||
<div key={m.id} className="card" style={{ margin: 0 }}>
|
<div key={m.id} className="card" style={{ margin: 0 }}>
|
||||||
<div className="row-between">
|
<div className="row-between">
|
||||||
<div>
|
<div>
|
||||||
<b>#{m.id}</b> · {m.played_at}
|
<b>#{m.id}</b> · {formatDate(m.played_at)}
|
||||||
{!grouped && <span className="muted small"> · {m.group_name}</span>}
|
{!grouped && <span className="muted small"> · {m.group_name}</span>}
|
||||||
<div className="muted small">
|
<div className="muted small">
|
||||||
{m.player_count} игр. · автор {m.created_by}
|
{m.player_count} {plural(m.player_count, "игрок", "игрока", "игроков")} · автор{" "}
|
||||||
|
{m.created_by}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="row" style={{ gap: 4 }}>
|
<div className="row" style={{ gap: 4 }}>
|
||||||
<button className="btn btn-ghost small" onClick={() => setEditId(m.id)}>
|
<button className="btn btn-ghost small" onClick={() => setEditId(m.id)}>
|
||||||
<Pencil size={16} />
|
<Pencil size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className="btn btn-ghost btn-danger small" onClick={() => setDeleteTarget(m)}>
|
||||||
className="btn btn-ghost btn-danger small"
|
|
||||||
onClick={async () => {
|
|
||||||
await del.mutateAsync(m.id).catch(() => {});
|
|
||||||
toast.show("Удалено");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,12 +112,7 @@ export function AdminRecordsPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<label className="row" style={{ gap: 8 }}>
|
<label className="row" style={{ gap: 8 }}>
|
||||||
<input
|
<Switch checked={grouped} onChange={setGrouped} />
|
||||||
type="checkbox"
|
|
||||||
style={{ width: "auto" }}
|
|
||||||
checked={grouped}
|
|
||||||
onChange={(e) => setGrouped(e.target.checked)}
|
|
||||||
/>
|
|
||||||
Группировать по группам
|
Группировать по группам
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,7 +121,7 @@ export function AdminRecordsPage() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
) : (matches ?? []).length === 0 ? (
|
) : (matches ?? []).length === 0 ? (
|
||||||
<div className="muted small">Записей нет.</div>
|
<div className="muted small">Записей пока нет.</div>
|
||||||
) : grouped ? (
|
) : grouped ? (
|
||||||
byGroup.map(([gid, { name, items }]) => (
|
byGroup.map(([gid, { name, items }]) => (
|
||||||
<div key={gid} style={{ marginBottom: 16 }}>
|
<div key={gid} style={{ marginBottom: 16 }}>
|
||||||
@@ -141,6 +136,25 @@ export function AdminRecordsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{editId != null && <AdminMatchEdit matchId={editId} onClose={() => setEditId(null)} />}
|
{editId != null && <AdminMatchEdit matchId={editId} onClose={() => setEditId(null)} />}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteTarget != null}
|
||||||
|
danger
|
||||||
|
title={`Удалить партию #${deleteTarget?.id}?`}
|
||||||
|
message="Партия и её результаты будут удалены. Действие необратимо."
|
||||||
|
confirmLabel="Удалить"
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setDeleteTarget(null);
|
||||||
|
try {
|
||||||
|
await del.mutateAsync(deleteTarget.id);
|
||||||
|
toast.show("Удалено");
|
||||||
|
} catch (e) {
|
||||||
|
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
import { ApiError, api, unwrap } from "../../api/client";
|
import { ApiError, api, unwrap } from "../../api/client";
|
||||||
import { qk } from "../../api/queryKeys";
|
import { qk } from "../../api/queryKeys";
|
||||||
|
import { ConfirmDialog } from "../../components/ConfirmDialog";
|
||||||
import { useToast } from "../../context/ToastContext";
|
import { useToast } from "../../context/ToastContext";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,6 +23,7 @@ export function DevDeleteAccountButton({
|
|||||||
}) {
|
}) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const del = useMutation({
|
const del = useMutation({
|
||||||
mutationFn: async () =>
|
mutationFn: async () =>
|
||||||
unwrap(
|
unwrap(
|
||||||
@@ -32,29 +35,34 @@ export function DevDeleteAccountButton({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<>
|
||||||
className="btn small"
|
<button
|
||||||
// Яркая (сплошная) красная кнопка. Стиль инлайном, а не классом в общем CSS,
|
className="btn small"
|
||||||
// чтобы в прод/тест-стили не попало ничего, связанного с удалением.
|
// Яркая (сплошная) красная кнопка. Стиль инлайном, а не классом в общем CSS,
|
||||||
style={{ background: "var(--danger)", borderColor: "var(--danger)", color: "#fff" }}
|
// чтобы в прод/тест-стили не попало ничего, связанного с удалением.
|
||||||
disabled={del.isPending}
|
style={{ background: "var(--danger)", borderColor: "var(--danger)", color: "#fff" }}
|
||||||
onClick={async () => {
|
disabled={del.isPending}
|
||||||
if (
|
onClick={() => setConfirmOpen(true)}
|
||||||
!window.confirm(
|
>
|
||||||
`Удалить аккаунт «${nickname}»? Партии сохранятся, но игрок будет ` +
|
Удалить
|
||||||
`вычеркнут из них. Действие необратимо.`,
|
</button>
|
||||||
)
|
<ConfirmDialog
|
||||||
)
|
open={confirmOpen}
|
||||||
return;
|
danger
|
||||||
try {
|
title={`Удалить аккаунт «${nickname}»?`}
|
||||||
await del.mutateAsync();
|
message="Партии сохранятся, но игрок будет вычеркнут из них. Действие необратимо."
|
||||||
toast.show("Аккаунт удалён");
|
confirmLabel="Удалить"
|
||||||
} catch (e) {
|
onCancel={() => setConfirmOpen(false)}
|
||||||
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
|
onConfirm={async () => {
|
||||||
}
|
setConfirmOpen(false);
|
||||||
}}
|
try {
|
||||||
>
|
await del.mutateAsync();
|
||||||
Удалить
|
toast.show("Аккаунт удалён");
|
||||||
</button>
|
} catch (e) {
|
||||||
|
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger); }
|
.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger); }
|
||||||
.btn-danger-solid { background: var(--danger); border-color: var(--danger); color: #fff; }
|
.btn-danger-solid { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||||
.btn[disabled] { opacity: 0.5; pointer-events: none; }
|
.btn[disabled] { opacity: 0.5; pointer-events: none; }
|
||||||
|
.btn-block { width: 100%; }
|
||||||
|
|
||||||
.row { display: flex; align-items: center; gap: 10px; }
|
.row { display: flex; align-items: center; gap: 10px; }
|
||||||
.row-between { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
.row-between { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||||
@@ -91,6 +92,8 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
/* Бейдж незавершённой партии («идёт») */
|
||||||
|
.badge-live { color: var(--accent-2); }
|
||||||
|
|
||||||
.error-text { color: var(--danger); font-size: 13px; }
|
.error-text { color: var(--danger); font-size: 13px; }
|
||||||
|
|
||||||
@@ -286,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;
|
||||||
|
|||||||
@@ -106,6 +106,8 @@
|
|||||||
.lb-rank.top { color: var(--gold); }
|
.lb-rank.top { color: var(--gold); }
|
||||||
.lb-metrics { display: flex; gap: 12px; font-size: 13px; color: var(--text-muted); }
|
.lb-metrics { display: flex; gap: 12px; font-size: 13px; color: var(--text-muted); }
|
||||||
.lb-score { font-weight: 700; color: var(--accent-2); }
|
.lb-score { font-weight: 700; color: var(--accent-2); }
|
||||||
|
/* Рейтинг новичков (мало игр) — серебряный, не золотой. */
|
||||||
|
.lb-score.provisional { color: var(--silver); }
|
||||||
|
|
||||||
/* Таблица общей статистики (сортируемая, кликабельные строки) */
|
/* Таблица общей статистики (сортируемая, кликабельные строки) */
|
||||||
.st-row, .st-head {
|
.st-row, .st-head {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
--success: #4caf7d;
|
--success: #4caf7d;
|
||||||
--danger: #e0533d;
|
--danger: #e0533d;
|
||||||
--gold: #f0c14b;
|
--gold: #f0c14b;
|
||||||
|
--silver: #c0c6cf;
|
||||||
|
|
||||||
/* Размеры */
|
/* Размеры */
|
||||||
--app-max-w: 480px;
|
--app-max-w: 480px;
|
||||||
|
|||||||
Reference in New Issue
Block a user