История игр в профиле игрока (#1) #21

Merged
NotBigGhost merged 2 commits from issue-1-profile-match-history into dev 2026-09-08 22:49:52 +03:00
14 changed files with 622 additions and 15 deletions
@@ -0,0 +1,50 @@
"""Пользователь: настройки витрины истории партий в профиле.
Идемпотентна: на свежей БД столбцы создаёт 0001 (create_all из актуальных моделей) -> no-op;
на существующей БД добавляет столбцы. render_as_batch включён в env.py (для SQLite).
Revision ID: 0011_user_history_prefs
Revises: 0010_user_favorite_faction
Create Date: 2026-09-07
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0011_user_history_prefs"
down_revision: Union[str, None] = "0010_user_favorite_faction"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
cols = {c["name"] for c in insp.get_columns("users")}
with op.batch_alter_table("users") as b:
if "history_mode" not in cols:
b.add_column(
sa.Column(
"history_mode", sa.String(8), nullable=False, server_default="all"
)
)
if "history_detail" not in cols:
b.add_column(
sa.Column(
"history_detail", sa.String(8), nullable=False, server_default="compact"
)
)
def downgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
cols = {c["name"] for c in insp.get_columns("users")}
with op.batch_alter_table("users") as b:
if "history_detail" in cols:
b.drop_column("history_detail")
if "history_mode" in cols:
b.drop_column("history_mode")
+14
View File
@@ -73,6 +73,10 @@ class User(SQLModel, table=True):
"role <> 'admin' OR password_hash IS NOT NULL",
name="ck_users_admin_has_password",
),
CheckConstraint("history_mode IN ('all','best')", name="ck_users_history_mode"),
CheckConstraint(
"history_detail IN ('compact','full')", name="ck_users_history_detail"
),
)
id: int | None = Field(default=None, primary_key=True)
@@ -97,6 +101,16 @@ class User(SQLModel, table=True):
index=True,
)
)
# Витрина истории партий в профиле: что показывать (все / только лучшая по очкам)
# и насколько подробно. Действует и для гостей профиля, не только для владельца.
history_mode: str = Field(
default="all",
sa_column=Column(String(8), nullable=False, server_default="all"),
)
history_detail: str = Field(
default="compact",
sa_column=Column(String(8), nullable=False, server_default="compact"),
)
# Любимая фракция — личный выбор игрока в кастомизации профиля, а НЕ вычисление
# по истории партий (её считает «Чаще всего играет на»). NULL — выбор не сделан.
favorite_faction_id: int | None = Field(
+31
View File
@@ -32,6 +32,8 @@ def build_me(session: Session, user: User) -> s.MeRead:
bio=user.bio,
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
favorite_faction_id=user.favorite_faction_id,
history_mode=user.history_mode,
history_detail=user.history_detail,
groups=groups,
)
@@ -73,6 +75,8 @@ def update_me(
bio=user.bio,
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
favorite_faction_id=user.favorite_faction_id,
history_mode=user.history_mode,
history_detail=user.history_detail,
)
@@ -90,6 +94,13 @@ def update_my_profile(
user_service.update_bio(session, user, changed["bio"])
if "favorite_faction_id" in changed:
user_service.update_favorite_faction(session, user, changed["favorite_faction_id"])
if "history_mode" in changed or "history_detail" in changed:
user_service.update_history_prefs(
session,
user,
mode=changed.get("history_mode"),
detail=changed.get("history_detail"),
)
audit_service.record(
session,
actor_id=user.id,
@@ -155,6 +166,26 @@ def get_user_profile(
return user_service.public_profile(session, user_id)
@router.get("/{user_id}/matches", response_model=s.MatchHistory)
def user_matches(
user_id: int,
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> dict:
"""История партий игрока. Режим — витрина владельца профиля: её видят и гости."""
owner = user_service.get_user(session, user_id)
data = stats_service.user_match_list(
session,
user_id,
limit=limit,
offset=offset,
best_only=owner.history_mode == "best",
)
return {**data, "mode": owner.history_mode, "detail": owner.history_detail}
@router.get("/me/stats", response_model=s.ProfileStats)
def my_stats(
session: Session = Depends(get_session),
+19
View File
@@ -92,6 +92,9 @@ class UserRead(BaseModel):
avatar_url: str | None = None
# Любимая фракция — выбор игрока (id справочника); None — не выбрана.
favorite_faction_id: int | None = None
# Витрина истории партий в профиле.
history_mode: str = "all"
history_detail: str = "compact"
class MeRead(UserRead):
@@ -107,6 +110,8 @@ class ProfileUpdate(BaseModel):
# (роутер смотрит exclude_unset): правка «О себе» не трогает фракцию.
bio: str | None = None
favorite_faction_id: int | None = None
history_mode: str | None = None
history_detail: str | None = None
class ActiveGroupUpdate(BaseModel):
@@ -306,6 +311,20 @@ class LeaderboardEntry(OverallStats):
avatar_url: str | None = None
class MatchHistory(BaseModel):
"""История партий игрока плюс настройки витрины его профиля.
Настройки едут вместе со списком, чтобы гость отрисовал историю ровно так,
как выбрал её владелец, не делая второго запроса за профилем."""
items: list[MatchListItem] = []
total: int
limit: int
offset: int
mode: str
detail: str
class Leaderboard(BaseModel):
entries: list[LeaderboardEntry] = []
provisional: list[LeaderboardEntry] = []
+74 -12
View File
@@ -7,7 +7,7 @@ from sqlalchemy import text
from sqlmodel import Session, select
from app.core.timeutil import iso_utc
from app.models import Faction, Group, GroupMember, Match, User
from app.models import Faction, Group, GroupMember, Match, MatchParticipant, User
from app.services import faction_service, group_service, membership_service, user_service
from app.services.scoring import (
FACTION_MIN_GAMES,
@@ -300,18 +300,10 @@ def group_stats(session: Session, group_id: int) -> dict:
}
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
def _match_items(session: Session, matches) -> list[dict]:
"""Элементы списка партий (общее для списка группы и истории игрока)."""
from app.services.match_service import participants_detail # избегаем цикла импорта
total = len(session.exec(select(Match.id).where(Match.group_id == group_id)).all())
matches = session.exec(
select(Match)
.where(Match.group_id == group_id)
.order_by(Match.played_at.desc(), Match.id.desc())
.offset(offset)
.limit(limit)
).all()
items = []
for m in matches:
parts = []
@@ -343,7 +335,77 @@ def group_match_list(session: Session, group_id: int, limit: int = 20, offset: i
"participants": parts,
}
)
return {"items": items, "total": total, "limit": limit, "offset": offset}
return items
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
total = len(session.exec(select(Match.id).where(Match.group_id == group_id)).all())
matches = session.exec(
select(Match)
.where(Match.group_id == group_id)
.order_by(Match.played_at.desc(), Match.id.desc())
.offset(offset)
.limit(limit)
).all()
return {
"items": _match_items(session, matches),
"total": total,
"limit": limit,
"offset": offset,
}
def user_match_list(
session: Session,
user_id: int,
limit: int = 20,
offset: int = 0,
best_only: bool = False,
) -> dict:
"""История партий игрока: только ЗАВЕРШЁННЫЕ, свежие сверху.
best_only — одна лучшая партия по League Points (s.points из SCORED_CTE учитывает
место и размер стола); при равных очках берём более свежую."""
if best_only:
sql = f"""
{SCORED_CTE}
SELECT s.match_id AS match_id
FROM scored s
WHERE s.user_id = :uid
ORDER BY s.points DESC, s.played_at DESC, s.match_id DESC
LIMIT 1
"""
row = session.execute(text(sql), {"uid": user_id}).mappings().first()
matches = [session.get(Match, row["match_id"])] if row else []
return {
"items": _match_items(session, matches),
"total": len(matches),
"limit": 1,
"offset": 0,
}
where = (MatchParticipant.user_id == user_id, Match.status == "finished")
total = len(
session.exec(
select(Match.id)
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
.where(*where)
).all()
)
matches = session.exec(
select(Match)
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
.where(*where)
.order_by(Match.played_at.desc(), Match.id.desc())
.offset(offset)
.limit(limit)
).all()
return {
"items": _match_items(session, matches),
"total": total,
"limit": limit,
"offset": offset,
}
def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
+22
View File
@@ -185,6 +185,28 @@ def update_bio(session: Session, user: User, bio: str | None) -> User:
return user
_HISTORY_MODES = {"all", "best"}
_HISTORY_DETAILS = {"compact", "full"}
def update_history_prefs(
session: Session, user: User, *, mode: str | None = None, detail: str | None = None
) -> User:
"""Витрина истории партий: что показывать и насколько подробно. None — не менять."""
if mode is not None:
if mode not in _HISTORY_MODES:
raise ValidationError("Неизвестный режим истории партий.")
user.history_mode = mode
if detail is not None:
if detail not in _HISTORY_DETAILS:
raise ValidationError("Неизвестная подробность истории партий.")
user.history_detail = detail
session.add(user)
session.commit()
session.refresh(user)
return user
def update_favorite_faction(session: Session, user: User, faction_id: int | None) -> User:
"""Любимая фракция — личный выбор игрока; None очищает выбор."""
if faction_id is not None and session.get(Faction, faction_id) is None:
+162 -3
View File
@@ -1,10 +1,16 @@
"""Профиль: «о себе» (bio), любимая фракция, аватар (загрузка/отдача/удаление),
публичный профиль."""
"""Профиль: «о себе» (bio), любимая фракция, история партий, аватар
(загрузка/отдача/удаление), публичный профиль."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
from tests.conftest import (
add_group_member,
create_finished_match,
csrf_headers,
login,
start_match,
)
# Минимальный «PNG»: достаточно сигнатуры — сервер не декодирует, только сниффит тип.
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
@@ -240,3 +246,156 @@ def test_prepositional_dictionary_and_fallback():
)
# Фракция, заведённая админом мимо словаря, не роняет вывод.
assert faction_service.prepositional("custom_xeno", "Ксеносы") == "Ксеносы"
# ─── История партий в профиле (#1) ───────────────────────────────────────────
def _history(client: TestClient, user_id: int) -> dict:
r = client.get(f"/api/users/{user_id}/matches")
assert r.status_code == 200, r.text
return r.json()
def _group_with(client: TestClient, engine, *nicknames: str) -> tuple[int, list[int], list[int]]:
"""Группа со всеми дополнениями + перечисленные соседи. → (group_id, их user_id, faction_id)."""
exps = [e["id"] for e in client.get("/api/expansions").json()]
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
).json()["id"]
mates = [add_group_member(engine, gid, nick) for nick in nicknames]
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
return gid, mates, fids
def test_history_lists_only_own_finished_matches(client: TestClient, engine):
"""В историю идут только завершённые партии этого игрока."""
me = login(client, "Историк")
gid, (mate,), fids = _group_with(client, engine, "Сосед")
create_finished_match(
client,
gid,
[
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
{"user_id": mate, "faction_id": fids[1], "place": 2},
],
)
# Незавершённая партия мест не имеет и в историю попадать не должна.
assert start_match(
client,
gid,
[
{"user_id": me["id"], "faction_id": fids[2]},
{"user_id": mate, "faction_id": fids[3]},
],
).status_code == 200
data = _history(client, me["id"])
assert data["total"] == 1
assert [m["status"] for m in data["items"]] == ["finished"]
# Значения по умолчанию едут вместе со списком — гостю хватает одного запроса.
assert data["mode"] == "all"
assert data["detail"] == "compact"
def test_history_excludes_matches_without_the_player(client: TestClient, engine):
"""Чужая партия в историю игрока не попадает, даже внутри его группы."""
me = login(client, "Наблюдатель")
gid, (mate, third), fids = _group_with(client, engine, "Игрок2", "Игрок3")
# Партию заводит сосед, сам игрок в ней не участвует.
login(client, "Игрок2")
create_finished_match(
client,
gid,
[
{"user_id": mate, "faction_id": fids[0], "place": 1},
{"user_id": third, "faction_id": fids[1], "place": 2},
],
)
assert _history(client, me["id"])["total"] == 0
assert _history(client, mate)["total"] == 1
def test_history_best_mode_picks_highest_points(client: TestClient, engine):
"""Режим best берёт партию с максимальными League Points, а не самую свежую.
Второе место из четырёх даёт (4-2)/3 ≈ 0.67, второе из двух — (2-2)/1 = 0."""
me = login(client, "Лучший")
gid, (a, b, c), fids = _group_with(client, engine, "А", "Б", "В")
create_finished_match(
client,
gid,
[
{"user_id": a, "faction_id": fids[0], "place": 1},
{"user_id": me["id"], "faction_id": fids[1], "place": 2},
{"user_id": b, "faction_id": fids[2], "place": 3},
{"user_id": c, "faction_id": fids[3], "place": 4},
],
)
# Свежее, но по очкам хуже — последнее место на двоих.
create_finished_match(
client,
gid,
[
{"user_id": a, "faction_id": fids[0], "place": 1},
{"user_id": me["id"], "faction_id": fids[1], "place": 2},
],
)
client.patch(
"/api/users/me/profile", json={"history_mode": "best"}, headers=csrf_headers(client)
)
data = _history(client, me["id"])
assert data["mode"] == "best"
assert data["total"] == 1
assert data["items"][0]["player_count"] == 4 # старшая партия, но с лучшими очками
def test_history_prefs_saved_and_validated(client: TestClient):
"""Настройки витрины сохраняются; мусор отклоняется, не сбивая сохранённое."""
login(client, "Настройщик")
r = client.patch(
"/api/users/me/profile",
json={"history_mode": "best", "history_detail": "full"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
assert r.json()["history_mode"] == "best"
assert r.json()["history_detail"] == "full"
assert client.get("/api/users/me").json()["history_detail"] == "full"
bad = client.patch(
"/api/users/me/profile", json={"history_mode": "неведомое"}, headers=csrf_headers(client)
)
assert bad.status_code == 422
assert client.get("/api/users/me").json()["history_mode"] == "best"
def test_history_uses_owner_mode_for_guests(client: TestClient, engine):
"""Гость видит историю в том режиме, который выбрал владелец профиля."""
me = login(client, "Витрина")
gid, (mate,), fids = _group_with(client, engine, "Партнёр")
for _ in range(2):
create_finished_match(
client,
gid,
[
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
{"user_id": mate, "faction_id": fids[1], "place": 2},
],
)
client.patch(
"/api/users/me/profile",
json={"history_mode": "best", "history_detail": "full"},
headers=csrf_headers(client),
)
login(client, "Прохожий")
data = _history(client, me["id"])
assert data["mode"] == "best"
assert data["detail"] == "full"
assert data["total"] == 1
+1
View File
@@ -7,6 +7,7 @@ export const qk = {
factions: ["factions"] as const,
myStats: ["myStats"] as const,
publicProfile: (id: number) => ["publicProfile", id] as const,
userMatches: (id: number) => ["userMatches", id] as const,
userSearch: (q: string, limit: number) => ["userSearch", q, limit] as const,
groups: ["groups"] as const,
group: (id: number) => ["group", id] as const,
+102
View File
@@ -174,6 +174,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/users/{user_id}/matches": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* User Matches
* @description История партий игрока. Режим — витрина владельца профиля: её видят и гости.
*/
get: operations["user_matches_api_users__user_id__matches_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/users/me/stats": {
parameters: {
query?: never;
@@ -1599,6 +1619,30 @@ export interface components {
/** Faction Id */
faction_id?: number | null;
};
/**
* MatchHistory
* @description История партий игрока плюс настройки витрины его профиля.
*
* Настройки едут вместе со списком, чтобы гость отрисовал историю ровно так,
* как выбрал её владелец, не делая второго запроса за профилем.
*/
MatchHistory: {
/**
* Items
* @default []
*/
items: components["schemas"]["MatchListItem"][];
/** Total */
total: number;
/** Limit */
limit: number;
/** Offset */
offset: number;
/** Mode */
mode: string;
/** Detail */
detail: string;
};
/** MatchList */
MatchList: {
/**
@@ -1765,6 +1809,16 @@ export interface components {
avatar_url?: string | null;
/** Favorite Faction Id */
favorite_faction_id?: number | null;
/**
* History Mode
* @default all
*/
history_mode: string;
/**
* History Detail
* @default compact
*/
history_detail: string;
/**
* Groups
* @default []
@@ -1904,6 +1958,10 @@ export interface components {
bio?: string | null;
/** Favorite Faction Id */
favorite_faction_id?: number | null;
/** History Mode */
history_mode?: string | null;
/** History Detail */
history_detail?: string | null;
};
/** PublicProfile */
PublicProfile: {
@@ -2012,6 +2070,16 @@ export interface components {
avatar_url?: string | null;
/** Favorite Faction Id */
favorite_faction_id?: number | null;
/**
* History Mode
* @default all
*/
history_mode: string;
/**
* History Detail
* @default compact
*/
history_detail: string;
};
/**
* UserSuggestion
@@ -2354,6 +2422,40 @@ export interface operations {
};
};
};
user_matches_api_users__user_id__matches_get: {
parameters: {
query?: {
limit?: number;
offset?: number;
};
header?: never;
path: {
user_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MatchHistory"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
my_stats_api_users_me_stats_get: {
parameters: {
query?: never;
+60
View File
@@ -0,0 +1,60 @@
import { useNavigate } from "react-router-dom";
import { formatDate, formatDuration } from "../domain/format";
import type { MatchListItem } from "../domain/types";
import { MatchListView } from "./MatchList";
/**
* История партий игрока в профиле. Подробный режим — тот же список, что у группы
* (MatchListView со всеми участниками); компактный — строка с результатом самого
* владельца профиля, поэтому компонент и принимает его userId.
*/
export function MatchHistory({
items,
userId,
detail,
}: {
items: MatchListItem[];
userId: number;
detail: string;
}) {
const navigate = useNavigate();
if (items.length === 0) return <div className="muted small">Партий пока нет.</div>;
if (detail === "full") return <MatchListView items={items} />;
return (
<div className="stack">
{items.map((m) => {
const mine = m.participants.find((p) => p.user_id === userId);
return (
<button
key={m.id}
className="card row-between"
style={{ margin: 0, width: "100%", textAlign: "left" }}
onClick={() => navigate(`/match/${m.id}`)}
>
<div className="row" style={{ gap: 10, minWidth: 0 }}>
<span
className={
"place-badge" +
(mine?.place === 1 && !mine?.eliminated ? " first" : "") +
(mine?.eliminated ? " eliminated" : "")
}
>
{mine?.eliminated ? "✕" : (mine?.place ?? "—")}
</span>
<span
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{mine?.faction_name ?? "—"}
</span>
</div>
<span className="muted small" style={{ flexShrink: 0 }}>
{formatDate(m.played_at)} · {formatDuration(m.duration_minutes)}
</span>
</button>
);
})}
</div>
);
}
+17
View File
@@ -137,6 +137,23 @@ export function useUpdateFavoriteFaction() {
});
}
// Витрина истории партий: режим (all/best) и подробность (compact/full).
// Шлём только изменившееся поле — роутер обновляет лишь пришедшие.
export function useUpdateHistoryPrefs() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (prefs: {
history_mode?: string;
history_detail?: string;
}): Promise<Me> => unwrap(await api.PATCH("/api/users/me/profile", { body: prefs })),
onSuccess: (me) => {
qc.setQueryData(qk.me, me);
// Список истории приходит вместе с режимом — перезапрашиваем его целиком.
qc.invalidateQueries({ queryKey: qk.userMatches(me.id) });
},
});
}
// Аватар. Картинку уменьшаем на клиенте (≤512px) и грузим multipart'ом отдельным
// fetch (openapi-fetch неудобен для файлов); CSRF-токен из cookie ставим вручную.
function readCsrfToken(): string | null {
+15
View File
@@ -15,6 +15,21 @@ function useDebounced<T>(value: T, ms = 100): T {
return debounced;
}
/** История партий игрока. Режим и подробность приходят вместе со списком:
* это витрина ВЛАДЕЛЬЦА профиля, её видят и гости. */
export function useUserMatches(userId: number | null) {
return useQuery({
queryKey: userId ? qk.userMatches(userId) : ["userMatches", "none"],
enabled: userId != null,
queryFn: async () =>
unwrap(
await api.GET("/api/users/{user_id}/matches", {
params: { path: { user_id: userId as number } },
}),
),
});
}
/** Подсказки по нику среди всех активных игроков (GET /api/users/search). */
export function useUserSearch(q: string, limit = 3) {
const dq = useDebounced(q.trim());
+40
View File
@@ -3,9 +3,11 @@ import { useNavigate } from "react-router-dom";
import { ApiError } from "../api/client";
import { Avatar } from "../components/Avatar";
import { MatchHistory } from "../components/MatchHistory";
import { PickerSelect } from "../components/PickerSelect";
import { ProfileStatsCard } from "../components/ProfileStatsCard";
import { Spinner } from "../components/Spinner";
import { Switch } from "../components/Switch";
import { useToast } from "../context/ToastContext";
import {
useDeleteAvatar,
@@ -13,11 +15,13 @@ import {
useMe,
useUpdateBio,
useUpdateFavoriteFaction,
useUpdateHistoryPrefs,
useUpdateNickname,
useUploadAvatar,
} from "../hooks/auth";
import { useExpansions, useFactions } from "../hooks/reference";
import { useMyStats } from "../hooks/stats";
import { useUserMatches } from "../hooks/users";
export function AccountPage() {
const { data: me, isLoading } = useMe();
@@ -25,8 +29,10 @@ export function AccountPage() {
const updateNick = useUpdateNickname();
const updateBio = useUpdateBio();
const updateFavorite = useUpdateFavoriteFaction();
const updateHistory = useUpdateHistoryPrefs();
const { data: factions } = useFactions();
const { data: expansions } = useExpansions();
const { data: history } = useUserMatches(me?.id ?? null);
const uploadAvatar = useUploadAvatar();
const deleteAvatar = useDeleteAvatar();
const logout = useLogout();
@@ -55,6 +61,14 @@ export function AccountPage() {
}
};
const saveHistory = async (prefs: { history_mode?: string; history_detail?: string }) => {
try {
await updateHistory.mutateAsync(prefs);
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const saveNick = async () => {
const nick = nickname.trim();
if (!nick) return;
@@ -203,6 +217,32 @@ export function AccountPage() {
{stats && <ProfileStatsCard stats={stats} />}
<div className="card">
<h3>История игр</h3>
{/* Настройки — витрина профиля: в этом же виде историю видят и гости. */}
<div className="row-between">
<div>Только лучшая партия</div>
<Switch
checked={me.history_mode === "best"}
disabled={updateHistory.isPending}
onChange={(v) => saveHistory({ history_mode: v ? "best" : "all" })}
/>
</div>
<div className="row-between" style={{ marginTop: 10 }}>
<div>Подробные карточки</div>
<Switch
checked={me.history_detail === "full"}
disabled={updateHistory.isPending}
onChange={(v) => saveHistory({ history_detail: v ? "full" : "compact" })}
/>
</div>
<div style={{ marginTop: 12 }}>
{history && (
<MatchHistory items={history.items} userId={me.id} detail={history.detail} />
)}
</div>
</div>
<button className="btn btn-danger btn-block" onClick={doLogout}>
Выйти
</button>
+15
View File
@@ -3,12 +3,14 @@ import { useParams } from "react-router-dom";
import { ApiError } from "../api/client";
import { Avatar } from "../components/Avatar";
import { MatchHistory } from "../components/MatchHistory";
import { ProfileStatsCard } from "../components/ProfileStatsCard";
import { Spinner } from "../components/Spinner";
import { useToast } from "../context/ToastContext";
import { useMe } from "../hooks/auth";
import { useInviteMember } from "../hooks/groups";
import { usePublicProfile } from "../hooks/stats";
import { useUserMatches } from "../hooks/users";
/**
* Просмотр ЧУЖОГО профиля — отдельный read-only компонент (намеренно изолирован от
@@ -19,6 +21,7 @@ export function PublicProfilePage() {
const id = Number(userId);
const { data, isLoading, isError } = usePublicProfile(id);
const { data: me } = useMe();
const { data: history } = useUserMatches(Number.isFinite(id) ? id : null);
const invite = useInviteMember();
const toast = useToast();
const [pickOpen, setPickOpen] = useState(false);
@@ -98,6 +101,18 @@ export function PublicProfilePage() {
)}
<ProfileStatsCard stats={data.stats} title="Статистика" />
{/* История в том виде, который выбрал владелец профиля; контролов здесь нет. */}
{history && history.items.length > 0 && (
<div className="card">
<h3>{history.mode === "best" ? "Лучшая партия" : "История игр"}</h3>
<MatchHistory
items={history.items}
userId={data.user_id}
detail={history.detail}
/>
</div>
)}
</div>
);
}