UI: единый PlayerLink — ники игроков кликабельны во всех местах просмотра

Новый компонент PlayerLink (ссылка на /u/:id, кликабельно всё поле строки,
stopPropagation для вложенности в кликабельные карточки). Применён:
- Leaderboard и общая статистика (рефакторинг ручных Link);
- страница партии: результаты и состав;
- настройки группы: список участников;
- список партий и незавершённые на главной (карточки button -> div,
  т.к. ссылки внутри button — невалидная разметка).
«пригласил: X» в приглашениях остаётся текстом — в API нет id пригласившего.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 17:37:19 +03:00
co-authored by Claude Fable 5
parent d81f5d8964
commit cbf3cfc6e6
7 changed files with 68 additions and 28 deletions
+12 -3
View File
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { formatTime } from "../domain/format";
import type { HomeInProgressMatch } from "../domain/types";
import { PlayerLink } from "./PlayerLink";
export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) {
const navigate = useNavigate();
@@ -13,7 +14,9 @@ export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) {
<h3 style={{ color: "var(--accent-2)" }}>Незавершённые партии</h3>
<div className="stack">
{items.map((m) => (
<button
// div, а не button: внутри лежат ссылки на профили игроков,
// а интерактивные элементы внутри <button> — невалидная разметка.
<div
key={m.id}
className="row-between"
onClick={() => navigate(`/match/${m.id}`)}
@@ -25,6 +28,7 @@ export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) {
borderRadius: "var(--radius-sm)",
padding: 12,
color: "var(--text)",
cursor: "pointer",
}}
>
<div style={{ minWidth: 0 }}>
@@ -39,13 +43,18 @@ export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) {
className="small"
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{m.participants.map((p) => p.nickname).join(", ")}
{m.participants.map((p, i) => (
<span key={p.user_id}>
{i > 0 && ", "}
<PlayerLink userId={p.user_id}>{p.nickname}</PlayerLink>
</span>
))}
</div>
</div>
<div className="row" style={{ gap: 4, color: "var(--accent-2)", flexShrink: 0 }}>
Завершить <ChevronRight size={18} />
</div>
</button>
</div>
))}
</div>
</div>
+5 -11
View File
@@ -1,7 +1,6 @@
import { Link } from "react-router-dom";
import type { LeaderboardEntry } from "../domain/types";
import { Avatar } from "./Avatar";
import { PlayerLink } from "./PlayerLink";
function pct(v: number | null | undefined): string {
return v == null ? "—" : `${Math.round(v * 100)}%`;
@@ -9,14 +8,9 @@ function pct(v: number | null | undefined): string {
function Row({ entry }: { entry: LeaderboardEntry }) {
return (
// Вся строка — ссылка на профиль. stopPropagation: на главной карточка-обёртка
// ведёт в общую статистику, а клик по строке игрока — в его профиль.
<Link
to={`/u/${entry.user_id}`}
className="lb-row"
style={{ color: "inherit" }}
onClick={(e) => e.stopPropagation()}
>
// Вся строка — ссылка на профиль (на главной карточка-обёртка ведёт в общую
// статистику, а клик по строке игрока — в его профиль).
<PlayerLink userId={entry.user_id} className="lb-row">
<div className={"lb-rank" + (entry.rank && entry.rank <= 3 ? " top" : "")}>
{entry.rank ?? "—"}
</div>
@@ -30,7 +24,7 @@ function Row({ entry }: { entry: LeaderboardEntry }) {
</div>
</div>
<div className="lb-score">{entry.score ?? "—"}</div>
</Link>
</PlayerLink>
);
}
+8 -5
View File
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { formatDuration } from "../domain/format";
import { winReasonLabel } from "../domain/winReasons";
import type { MatchListItem } from "../domain/types";
import { PlayerLink } from "./PlayerLink";
export function MatchListView({ items }: { items: MatchListItem[] }) {
const navigate = useNavigate();
@@ -14,10 +15,12 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
const inProgress = m.status === "in_progress";
const sorted = [...m.participants].sort((a, b) => (a.place ?? 99) - (b.place ?? 99));
return (
<button
// div, а не button: внутри карточки лежат ссылки на профили игроков,
// а интерактивные элементы внутри <button> — невалидная разметка.
<div
key={m.id}
className="card"
style={{ textAlign: "left", width: "100%", margin: 0 }}
style={{ textAlign: "left", width: "100%", margin: 0, cursor: "pointer" }}
onClick={() => navigate(`/match/${m.id}`)}
>
<div className="row-between">
@@ -32,7 +35,7 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
</div>
<div className="stack" style={{ marginTop: 6, gap: 4 }}>
{sorted.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 }}>
<span
className={
"place-badge" +
@@ -44,10 +47,10 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
</span>
<span>{p.nickname}</span>
<span className="muted small">· {p.faction_name}</span>
</div>
</PlayerLink>
))}
</div>
</button>
</div>
);
})}
</div>
+31
View File
@@ -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>
);
}