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:
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import { formatTime } from "../domain/format";
|
import { formatTime } from "../domain/format";
|
||||||
import type { HomeInProgressMatch } from "../domain/types";
|
import type { HomeInProgressMatch } from "../domain/types";
|
||||||
|
import { PlayerLink } from "./PlayerLink";
|
||||||
|
|
||||||
export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) {
|
export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -13,7 +14,9 @@ export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) {
|
|||||||
<h3 style={{ color: "var(--accent-2)" }}>Незавершённые партии</h3>
|
<h3 style={{ color: "var(--accent-2)" }}>Незавершённые партии</h3>
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
{items.map((m) => (
|
{items.map((m) => (
|
||||||
<button
|
// div, а не button: внутри лежат ссылки на профили игроков,
|
||||||
|
// а интерактивные элементы внутри <button> — невалидная разметка.
|
||||||
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className="row-between"
|
className="row-between"
|
||||||
onClick={() => navigate(`/match/${m.id}`)}
|
onClick={() => navigate(`/match/${m.id}`)}
|
||||||
@@ -25,6 +28,7 @@ export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) {
|
|||||||
borderRadius: "var(--radius-sm)",
|
borderRadius: "var(--radius-sm)",
|
||||||
padding: 12,
|
padding: 12,
|
||||||
color: "var(--text)",
|
color: "var(--text)",
|
||||||
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
@@ -39,13 +43,18 @@ export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) {
|
|||||||
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, i) => (
|
||||||
|
<span key={p.user_id}>
|
||||||
|
{i > 0 && ", "}
|
||||||
|
<PlayerLink userId={p.user_id}>{p.nickname}</PlayerLink>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="row" style={{ gap: 4, color: "var(--accent-2)", flexShrink: 0 }}>
|
<div className="row" style={{ gap: 4, color: "var(--accent-2)", flexShrink: 0 }}>
|
||||||
Завершить <ChevronRight size={18} />
|
Завершить <ChevronRight size={18} />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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)}%`;
|
||||||
@@ -9,14 +8,9 @@ function pct(v: number | null | undefined): string {
|
|||||||
|
|
||||||
function Row({ entry }: { entry: LeaderboardEntry }) {
|
function Row({ entry }: { entry: LeaderboardEntry }) {
|
||||||
return (
|
return (
|
||||||
// Вся строка — ссылка на профиль. stopPropagation: на главной карточка-обёртка
|
// Вся строка — ссылка на профиль (на главной карточка-обёртка ведёт в общую
|
||||||
// ведёт в общую статистику, а клик по строке игрока — в его профиль.
|
// статистику, а клик по строке игрока — в его профиль).
|
||||||
<Link
|
<PlayerLink userId={entry.user_id} className="lb-row">
|
||||||
to={`/u/${entry.user_id}`}
|
|
||||||
className="lb-row"
|
|
||||||
style={{ color: "inherit" }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<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>
|
||||||
@@ -30,7 +24,7 @@ function Row({ entry }: { entry: LeaderboardEntry }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="lb-score">{entry.score ?? "—"}</div>
|
<div className="lb-score">{entry.score ?? "—"}</div>
|
||||||
</Link>
|
</PlayerLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { formatDuration } from "../domain/format";
|
import { 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";
|
||||||
|
import { PlayerLink } from "./PlayerLink";
|
||||||
|
|
||||||
export function MatchListView({ items }: { items: MatchListItem[] }) {
|
export function MatchListView({ items }: { items: MatchListItem[] }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -14,10 +15,12 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
|
|||||||
const inProgress = m.status === "in_progress";
|
const inProgress = m.status === "in_progress";
|
||||||
const sorted = [...m.participants].sort((a, b) => (a.place ?? 99) - (b.place ?? 99));
|
const sorted = [...m.participants].sort((a, b) => (a.place ?? 99) - (b.place ?? 99));
|
||||||
return (
|
return (
|
||||||
<button
|
// div, а не button: внутри карточки лежат ссылки на профили игроков,
|
||||||
|
// а интерактивные элементы внутри <button> — невалидная разметка.
|
||||||
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className="card"
|
className="card"
|
||||||
style={{ textAlign: "left", width: "100%", margin: 0 }}
|
style={{ textAlign: "left", width: "100%", margin: 0, cursor: "pointer" }}
|
||||||
onClick={() => navigate(`/match/${m.id}`)}
|
onClick={() => navigate(`/match/${m.id}`)}
|
||||||
>
|
>
|
||||||
<div className="row-between">
|
<div className="row-between">
|
||||||
@@ -32,7 +35,7 @@ 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 }}>
|
<PlayerLink key={p.user_id} userId={p.user_id} className="row" style={{ gap: 8 }}>
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
"place-badge" +
|
"place-badge" +
|
||||||
@@ -44,10 +47,10 @@ 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>
|
||||||
</div>
|
</PlayerLink>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Trash2, UserPlus } from "lucide-react";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
|
import { PlayerLink } from "../components/PlayerLink";
|
||||||
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 { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
@@ -118,9 +119,10 @@ export function GroupSettingsPage() {
|
|||||||
<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>
|
{/* Кликабельна вся левая часть строки, не только текст ника. */}
|
||||||
|
<PlayerLink userId={m.user_id} style={{ flex: 1 }}>
|
||||||
{m.nickname} <span className="muted small">· {m.role}</span>
|
{m.nickname} <span className="muted small">· {m.role}</span>
|
||||||
</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={() => remove(m.user_id)}>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 { formatDuration, formatTime } from "../domain/format";
|
||||||
import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
|
import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -43,7 +43,7 @@ export function OverallStatsPage() {
|
|||||||
const arrow = (k: SortKey) => (k === sortKey ? (dir === 1 ? " ↑" : " ↓") : "");
|
const arrow = (k: SortKey) => (k === sortKey ? (dir === 1 ? " ↑" : " ↓") : "");
|
||||||
|
|
||||||
const row = (e: LeaderboardEntry) => (
|
const row = (e: LeaderboardEntry) => (
|
||||||
<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} />
|
||||||
@@ -53,7 +53,7 @@ export function OverallStatsPage() {
|
|||||||
<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">{e.score ?? "—"}</span>
|
||||||
</Link>
|
</PlayerLink>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user