diff --git a/backend/alembic/versions/0011_user_history_prefs.py b/backend/alembic/versions/0011_user_history_prefs.py new file mode 100644 index 0000000..539cb44 --- /dev/null +++ b/backend/alembic/versions/0011_user_history_prefs.py @@ -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") diff --git a/backend/app/models.py b/backend/app/models.py index 445f093..be047e8 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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( diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 9f91354..0f374e4 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -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), diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index aeae3dd..aedfa14 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -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] = [] diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index 13eeb4f..690f537 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -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]: diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 2dd6831..ea6d552 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -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: diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py index 2f346af..477b25f 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -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 diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index 951a533..99e8c19 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -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, diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index c5830ac..2b2a03b 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -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; diff --git a/frontend/src/components/MatchHistory.tsx b/frontend/src/components/MatchHistory.tsx new file mode 100644 index 0000000..b03c6d5 --- /dev/null +++ b/frontend/src/components/MatchHistory.tsx @@ -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
Партий пока нет.
; + if (detail === "full") return ; + + return ( +
+ {items.map((m) => { + const mine = m.participants.find((p) => p.user_id === userId); + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/hooks/auth.ts b/frontend/src/hooks/auth.ts index 4c36ee8..2982779 100644 --- a/frontend/src/hooks/auth.ts +++ b/frontend/src/hooks/auth.ts @@ -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 => 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 { diff --git a/frontend/src/hooks/users.ts b/frontend/src/hooks/users.ts index 191a328..4502ecd 100644 --- a/frontend/src/hooks/users.ts +++ b/frontend/src/hooks/users.ts @@ -15,6 +15,21 @@ function useDebounced(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()); diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index 0914290..871d573 100644 --- a/frontend/src/pages/AccountPage.tsx +++ b/frontend/src/pages/AccountPage.tsx @@ -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 && } +
+

История игр

+ {/* Настройки — витрина профиля: в этом же виде историю видят и гости. */} +
+
Только лучшая партия
+ saveHistory({ history_mode: v ? "best" : "all" })} + /> +
+
+
Подробные карточки
+ saveHistory({ history_detail: v ? "full" : "compact" })} + /> +
+
+ {history && ( + + )} +
+
+ diff --git a/frontend/src/pages/PublicProfilePage.tsx b/frontend/src/pages/PublicProfilePage.tsx index 6b5a843..9c8c0dc 100644 --- a/frontend/src/pages/PublicProfilePage.tsx +++ b/frontend/src/pages/PublicProfilePage.tsx @@ -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() { )} + + {/* История в том виде, который выбрал владелец профиля; контролов здесь нет. */} + {history && history.items.length > 0 && ( +
+

{history.mode === "best" ? "Лучшая партия" : "История игр"}

+ +
+ )} ); }