Кастомизация профиля (о себе, аватар) и просмотр профилей других игроков + общая статистика

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 17:27:24 +03:00
co-authored by Claude Opus 4.8
parent b302ec769a
commit 96daddae05
22 changed files with 1106 additions and 23 deletions
@@ -0,0 +1,47 @@
"""Кастомизация профиля: о себе (bio) и аватар (avatar_path).
Идемпотентна: на свежей БД (0001 создаёт схему из актуальных моделей через create_all)
столбцы уже есть → no-op; на существующей БД — добавляет столбцы. render_as_batch включён
в env.py (нужен для ALTER в SQLite).
Revision ID: 0004_user_profile
Revises: 0003_match_lifecycle
Create Date: 2026-06-17
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0004_user_profile"
down_revision: Union[str, None] = "0003_match_lifecycle"
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")}
add = []
if "bio" not in cols:
add.append(sa.Column("bio", sa.Text(), nullable=True))
if "avatar_path" not in cols:
add.append(sa.Column("avatar_path", sa.String(255), nullable=True))
if add:
with op.batch_alter_table("users") as b:
for col in add:
b.add_column(col)
def downgrade() -> None:
with op.batch_alter_table("users") as b:
for name in ("avatar_path", "bio"):
try:
b.drop_column(name)
except Exception: # noqa: BLE001
pass
+3
View File
@@ -80,6 +80,9 @@ class User(SQLModel, table=True):
sa_column=Column(BigInteger, nullable=True, unique=True) sa_column=Column(BigInteger, nullable=True, unique=True)
) )
nickname: str = Field(sa_column=Column(String(64), nullable=False, unique=True)) nickname: str = Field(sa_column=Column(String(64), nullable=False, unique=True))
bio: str | None = Field(default=None, sa_column=Column(Text, nullable=True))
# Относительный путь файла аватара (под settings.upload_dir); наличие = есть аватар.
avatar_path: str | None = Field(default=None, sa_column=Column(String(255), nullable=True))
role: str = Field(default="player", sa_column=Column(String(16), nullable=False, index=True, server_default="player")) role: str = Field(default="player", sa_column=Column(String(16), nullable=False, index=True, server_default="player"))
auth_provider: str = Field( auth_provider: str = Field(
default="stub", default="stub",
+82 -1
View File
@@ -1,10 +1,12 @@
"""Роутер текущего пользователя.""" """Роутер текущего пользователя."""
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
from fastapi.responses import FileResponse
from sqlmodel import Session from sqlmodel import Session
from app.auth.deps import get_current_user from app.auth.deps import get_current_user
from app.core.errors import NotFoundError, ValidationError
from app.db.session import get_session from app.db.session import get_session
from app.models import User from app.models import User
from app.schemas import api as s from app.schemas import api as s
@@ -12,6 +14,8 @@ from app.services import audit_service, group_service, stats_service, user_servi
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
_AVATAR_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ
def build_me(session: Session, user: User) -> s.MeRead: def build_me(session: Session, user: User) -> s.MeRead:
groups = [ groups = [
@@ -25,6 +29,8 @@ def build_me(session: Session, user: User) -> s.MeRead:
auth_provider=user.auth_provider, auth_provider=user.auth_provider,
telegram_id=user.telegram_id, telegram_id=user.telegram_id,
active_group_id=user.active_group_id, active_group_id=user.active_group_id,
bio=user.bio,
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
groups=groups, groups=groups,
) )
@@ -63,9 +69,84 @@ def update_me(
auth_provider=user.auth_provider, auth_provider=user.auth_provider,
telegram_id=user.telegram_id, telegram_id=user.telegram_id,
active_group_id=user.active_group_id, active_group_id=user.active_group_id,
bio=user.bio,
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
) )
@router.patch("/me/profile", response_model=s.MeRead)
def update_my_profile(
body: s.ProfileUpdate,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MeRead:
user_service.update_bio(session, user, body.bio)
audit_service.record(
session,
actor_id=user.id,
action="update",
entity_type="user",
entity_id=user.id,
payload={"bio": True},
ip=request.client.host if request.client else None,
)
session.commit()
return build_me(session, user)
@router.put("/me/avatar", response_model=s.MeRead)
def upload_my_avatar(
file: UploadFile = File(...),
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MeRead:
content = file.file.read(_AVATAR_MAX_BYTES + 1)
if len(content) > _AVATAR_MAX_BYTES:
raise ValidationError("Файл слишком большой (макс. 2 МБ).")
ext = user_service.sniff_image_ext(content)
if ext is None:
raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.")
user_service.set_avatar(session, user, content, ext)
return build_me(session, user)
@router.delete("/me/avatar", response_model=s.MeRead)
def delete_my_avatar(
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MeRead:
user_service.clear_avatar(session, user)
return build_me(session, user)
@router.get("/{user_id}/avatar")
def get_user_avatar(
user_id: int,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> FileResponse:
target = session.get(User, user_id)
path = user_service.avatar_abs_path(target.avatar_path) if target else None
if path is None or not path.exists():
raise NotFoundError("Аватар не найден.")
ext = path.suffix.lstrip(".")
return FileResponse(
path,
media_type=user_service.avatar_media_type(ext),
headers={"Cache-Control": "public, max-age=3600"},
)
@router.get("/{user_id}/profile", response_model=s.PublicProfile)
def get_user_profile(
user_id: int,
session: Session = Depends(get_session),
_user: User = Depends(get_current_user),
) -> dict:
return user_service.public_profile(session, user_id)
@router.get("/me/stats", response_model=s.ProfileStats) @router.get("/me/stats", response_model=s.ProfileStats)
def my_stats( def my_stats(
session: Session = Depends(get_session), session: Session = Depends(get_session),
+16
View File
@@ -88,6 +88,8 @@ class UserRead(BaseModel):
auth_provider: str auth_provider: str
telegram_id: int | None = None telegram_id: int | None = None
active_group_id: int | None = None active_group_id: int | None = None
bio: str | None = None
avatar_url: str | None = None
class MeRead(UserRead): class MeRead(UserRead):
@@ -98,6 +100,10 @@ class NicknameUpdate(BaseModel):
nickname: str nickname: str
class ProfileUpdate(BaseModel):
bio: str | None = None
class ActiveGroupUpdate(BaseModel): class ActiveGroupUpdate(BaseModel):
group_id: int | None = None group_id: int | None = None
@@ -237,6 +243,7 @@ class LeaderboardEntry(OverallStats):
user_id: int user_id: int
nickname: str nickname: str
rank: int | None = None rank: int | None = None
avatar_url: str | None = None
class Leaderboard(BaseModel): class Leaderboard(BaseModel):
@@ -273,6 +280,15 @@ class ProfileStats(BaseModel):
recent_form: list[RecentFormItem] = [] recent_form: list[RecentFormItem] = []
class PublicProfile(BaseModel):
# Профиль другого игрока (read-only): шапка + глобальная статистика.
user_id: int
nickname: str
bio: str | None = None
avatar_url: str | None = None
stats: ProfileStats
class FactionMeta(BaseModel): class FactionMeta(BaseModel):
faction_id: int faction_id: int
code: str code: str
+8 -1
View File
@@ -49,6 +49,10 @@ def _round(value: Any, ndigits: int) -> float | None:
def _normalize(row: dict) -> dict: def _normalize(row: dict) -> dict:
# avatar_url мирроринг user_service.avatar_url_for: версия = epoch(updated_at) из SQL.
avatar_url = None
if row.get("avatar_path"):
avatar_url = f"/api/users/{row['user_id']}/avatar?v={int(row.get('avatar_version') or 0)}"
return { return {
"user_id": row["user_id"], "user_id": row["user_id"],
"nickname": row["nickname"], "nickname": row["nickname"],
@@ -57,6 +61,7 @@ def _normalize(row: dict) -> dict:
"win_rate": _round(row["win_rate"] or 0.0, 4), "win_rate": _round(row["win_rate"] or 0.0, 4),
"avg_place": _round(row["avg_place"], 2), "avg_place": _round(row["avg_place"], 2),
"score": _round(row["score"], 1), "score": _round(row["score"], 1),
"avatar_url": avatar_url,
} }
@@ -65,6 +70,8 @@ def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]:
sql = f""" sql = f"""
{SCORED_CTE} {SCORED_CTE}
SELECT u.id AS user_id, u.nickname AS nickname, SELECT u.id AS user_id, u.nickname AS nickname,
u.avatar_path AS avatar_path,
CAST(strftime('%s', u.updated_at) AS INTEGER) AS avatar_version,
COUNT(*) AS games, COUNT(*) AS games,
SUM(s.is_win) AS wins, SUM(s.is_win) AS wins,
AVG(CAST(s.is_win AS FLOAT)) AS win_rate, AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
@@ -73,7 +80,7 @@ def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]:
FROM scored s FROM scored s
JOIN users u ON u.id = s.user_id JOIN users u ON u.id = s.user_id
{where} {where}
GROUP BY u.id, u.nickname GROUP BY u.id, u.nickname, u.avatar_path, u.updated_at
""" """
params = {"gid": group_id} if group_id is not None else {} params = {"gid": group_id} if group_id is not None else {}
result = session.execute(text(sql), params).mappings().all() result = session.execute(text(sql), params).mappings().all()
+105 -1
View File
@@ -1,15 +1,24 @@
"""Пользователи: создание из внешней личности, ник, активная группа.""" """Пользователи: создание из внешней личности, ник, активная группа, профиль."""
from __future__ import annotations from __future__ import annotations
import os
import re import re
from datetime import datetime
from pathlib import Path
from sqlmodel import Session, select from sqlmodel import Session, select
from app.auth.provider import ExternalIdentity from app.auth.provider import ExternalIdentity
from app.core.config import settings
from app.core.errors import NicknameTakenError, NotFoundError, ValidationError from app.core.errors import NicknameTakenError, NotFoundError, ValidationError
from app.models import AuthIdentity, GroupMember, User from app.models import AuthIdentity, GroupMember, User
_NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE) _NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE)
_BIO_MAX = 500
# Аватары: файл на диске под settings.upload_dir/avatars, путь относительный.
_AVATAR_SUBDIR = "avatars"
_IMAGE_MEDIA = {"png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"}
def get_user(session: Session, user_id: int) -> User: def get_user(session: Session, user_id: int) -> User:
@@ -128,3 +137,98 @@ def set_active_group(session: Session, user: User, group_id: int | None) -> User
session.commit() session.commit()
session.refresh(user) session.refresh(user)
return user return user
# ─── Профиль: «о себе», аватар ────────────────────────────────────────────────
def avatar_url_for(user_id: int, avatar_path: str | None, updated_at: datetime | None) -> str | None:
"""URL аватара с кэш-бастингом по updated_at. None, если аватара нет.
Один источник для build_me, публичного профиля и лидерборда — чтобы клиент
подтягивал новую картинку после смены (файл перезаписывается по тому же пути)."""
if not avatar_path:
return None
version = int(updated_at.timestamp()) if updated_at else 0
return f"/api/users/{user_id}/avatar?v={version}"
def update_bio(session: Session, user: User, bio: str | None) -> User:
bio = (bio or "").strip()
if len(bio) > _BIO_MAX:
raise ValidationError(f"«О себе»: не более {_BIO_MAX} символов.")
user.bio = bio or None
session.add(user)
session.commit()
session.refresh(user)
return user
def sniff_image_ext(content: bytes) -> str | None:
"""Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка."""
if content.startswith(b"\x89PNG\r\n\x1a\n"):
return "png"
if content.startswith(b"\xff\xd8\xff"):
return "jpg"
if len(content) >= 12 and content[0:4] == b"RIFF" and content[8:12] == b"WEBP":
return "webp"
return None
def avatar_media_type(ext: str) -> str:
return _IMAGE_MEDIA.get(ext, "application/octet-stream")
def avatar_abs_path(avatar_path: str | None) -> Path | None:
"""Абсолютный путь файла аватара (или None), для отдачи через FileResponse."""
if not avatar_path:
return None
return Path(settings.upload_dir) / avatar_path
def set_avatar(session: Session, user: User, content: bytes, ext: str) -> User:
avatars_dir = Path(settings.upload_dir) / _AVATAR_SUBDIR
avatars_dir.mkdir(parents=True, exist_ok=True)
# Удаляем прежний файл (мог быть с другим расширением), затем пишем новый.
old = avatar_abs_path(user.avatar_path)
if old and old.exists():
try:
old.unlink()
except OSError:
pass
rel = f"{_AVATAR_SUBDIR}/{user.id}.{ext}"
(Path(settings.upload_dir) / rel).write_bytes(content)
user.avatar_path = rel
session.add(user)
session.commit()
session.refresh(user)
return user
def clear_avatar(session: Session, user: User) -> User:
path = avatar_abs_path(user.avatar_path)
if path and path.exists():
try:
path.unlink()
except OSError:
pass
user.avatar_path = None
session.add(user)
session.commit()
session.refresh(user)
return user
def public_profile(session: Session, user_id: int) -> dict:
"""Публичный профиль игрока: ник/«о себе»/аватар + глобальная статистика."""
from app.services import stats_service # ленивый импорт (избегаем цикла)
user = session.get(User, user_id)
if user is None:
raise NotFoundError("Пользователь не найден.")
return {
"user_id": user.id,
"nickname": user.nickname,
"bio": user.bio,
"avatar_url": avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
"stats": stats_service.profile_stats(session, user_id, group_id=None),
}
+129
View File
@@ -0,0 +1,129 @@
"""Профиль: «о себе» (bio), аватар (загрузка/отдача/удаление), публичный профиль."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import create_finished_match, csrf_headers, login
# Минимальный «PNG»: достаточно сигнатуры — сервер не декодирует, только сниффит тип.
PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64
def _use_tmp_uploads(monkeypatch, tmp_path) -> None:
from app.core.config import settings
monkeypatch.setattr(settings, "dev_upload_dir", str(tmp_path))
def _finished_match_for(client: TestClient, me: dict) -> int:
"""Группа + второй игрок + одна завершённая партия (чтобы me попал в лидерборд)."""
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"]
client.post("/api/auth/dev/users", json={"nickname": "Соперник"}, headers=csrf_headers(client))
p2 = client.post(
f"/api/groups/{gid}/members", json={"nickname": "Соперник"}, headers=csrf_headers(client)
).json()["user_id"]
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
create_finished_match(
client,
gid,
[
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
{"user_id": p2, "faction_id": fids[1], "place": 2},
],
)
return gid
def test_update_bio(client: TestClient):
login(client, "Игрок")
r = client.patch("/api/users/me/profile", json={"bio": "Люблю орков"}, headers=csrf_headers(client))
assert r.status_code == 200, r.text
assert r.json()["bio"] == "Люблю орков"
# Слишком длинное «о себе» → 422.
r = client.patch(
"/api/users/me/profile", json={"bio": "x" * 501}, headers=csrf_headers(client)
)
assert r.status_code == 422, r.text
def test_avatar_upload_get_delete(client: TestClient, monkeypatch, tmp_path):
_use_tmp_uploads(monkeypatch, tmp_path)
me = login(client, "Аватарыч")
# Загрузка валидного PNG → avatar_url выставлен.
r = client.put(
"/api/users/me/avatar",
files={"file": ("a.png", PNG, "image/png")},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
avatar_url = r.json()["avatar_url"]
assert avatar_url and "/avatar?v=" in avatar_url
# Отдача файла.
g = client.get(f"/api/users/{me['id']}/avatar")
assert g.status_code == 200, g.text
assert g.content == PNG
assert g.headers["content-type"] == "image/png"
# Удаление → avatar_url пропадает, файл больше не отдаётся.
r = client.delete("/api/users/me/avatar", headers=csrf_headers(client))
assert r.status_code == 200 and r.json()["avatar_url"] is None, r.text
assert client.get(f"/api/users/{me['id']}/avatar").status_code == 404
def test_avatar_rejects_non_image_and_oversize(client: TestClient, monkeypatch, tmp_path):
_use_tmp_uploads(monkeypatch, tmp_path)
login(client, "Игрок")
r = client.put(
"/api/users/me/avatar",
files={"file": ("x.txt", b"not an image", "text/plain")},
headers=csrf_headers(client),
)
assert r.status_code == 422, r.text
big = PNG + b"\x00" * (2 * 1024 * 1024 + 10)
r = client.put(
"/api/users/me/avatar",
files={"file": ("big.png", big, "image/png")},
headers=csrf_headers(client),
)
assert r.status_code == 422, r.text
def test_public_profile(client: TestClient):
me = login(client, "Публичный")
client.patch("/api/users/me/profile", json={"bio": "Привет"}, headers=csrf_headers(client))
r = client.get(f"/api/users/{me['id']}/profile")
assert r.status_code == 200, r.text
body = r.json()
assert body["nickname"] == "Публичный"
assert body["bio"] == "Привет"
assert body["avatar_url"] is None
assert "overall" in body["stats"]
assert client.get("/api/users/999999/profile").status_code == 404
def test_leaderboard_includes_avatar_url(client: TestClient, monkeypatch, tmp_path):
_use_tmp_uploads(monkeypatch, tmp_path)
me = login(client, "Топчик")
_finished_match_for(client, me)
def entry_for(uid: int) -> dict:
board = client.get("/api/stats/leaderboard").json()
return next(e for e in board["entries"] + board["provisional"] if e["user_id"] == uid)
assert entry_for(me["id"])["avatar_url"] is None # без аватара — null
client.put(
"/api/users/me/avatar",
files={"file": ("a.png", PNG, "image/png")},
headers=csrf_headers(client),
)
assert "/avatar?v=" in entry_for(me["id"])["avatar_url"] # после загрузки — ссылка
+1
View File
@@ -6,6 +6,7 @@ export const qk = {
expansions: ["expansions"] as const, expansions: ["expansions"] as const,
factions: ["factions"] as const, factions: ["factions"] as const,
myStats: ["myStats"] as const, myStats: ["myStats"] as const,
publicProfile: (id: number) => ["publicProfile", id] 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,
+249
View File
@@ -105,6 +105,75 @@ export interface paths {
patch: operations["update_me_api_users_me_patch"]; patch: operations["update_me_api_users_me_patch"];
trace?: never; trace?: never;
}; };
"/api/users/me/profile": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/** Update My Profile */
patch: operations["update_my_profile_api_users_me_profile_patch"];
trace?: never;
};
"/api/users/me/avatar": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/** Upload My Avatar */
put: operations["upload_my_avatar_api_users_me_avatar_put"];
post?: never;
/** Delete My Avatar */
delete: operations["delete_my_avatar_api_users_me_avatar_delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/users/{user_id}/avatar": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get User Avatar */
get: operations["get_user_avatar_api_users__user_id__avatar_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/users/{user_id}/profile": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get User Profile */
get: operations["get_user_profile_api_users__user_id__profile_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/users/me/stats": { "/api/users/me/stats": {
parameters: { parameters: {
query?: never; query?: never;
@@ -843,6 +912,11 @@ export interface components {
/** Telegram Bot Username */ /** Telegram Bot Username */
telegram_bot_username?: string | null; telegram_bot_username?: string | null;
}; };
/** Body_upload_my_avatar_api_users_me_avatar_put */
Body_upload_my_avatar_api_users_me_avatar_put: {
/** File */
file: string;
};
/** DevLogin */ /** DevLogin */
DevLogin: { DevLogin: {
/** Nickname */ /** Nickname */
@@ -1097,6 +1171,8 @@ export interface components {
nickname: string; nickname: string;
/** Rank */ /** Rank */
rank?: number | null; rank?: number | null;
/** Avatar Url */
avatar_url?: string | null;
}; };
/** MatchCreate */ /** MatchCreate */
MatchCreate: { MatchCreate: {
@@ -1272,6 +1348,10 @@ export interface components {
telegram_id?: number | null; telegram_id?: number | null;
/** Active Group Id */ /** Active Group Id */
active_group_id?: number | null; active_group_id?: number | null;
/** Bio */
bio?: string | null;
/** Avatar Url */
avatar_url?: string | null;
/** /**
* Groups * Groups
* @default [] * @default []
@@ -1363,6 +1443,23 @@ export interface components {
*/ */
recent_form: components["schemas"]["RecentFormItem"][]; recent_form: components["schemas"]["RecentFormItem"][];
}; };
/** ProfileUpdate */
ProfileUpdate: {
/** Bio */
bio?: string | null;
};
/** PublicProfile */
PublicProfile: {
/** User Id */
user_id: number;
/** Nickname */
nickname: string;
/** Bio */
bio?: string | null;
/** Avatar Url */
avatar_url?: string | null;
stats: components["schemas"]["ProfileStats"];
};
/** RandomizeRequest */ /** RandomizeRequest */
RandomizeRequest: { RandomizeRequest: {
/** Group Id */ /** Group Id */
@@ -1452,6 +1549,10 @@ export interface components {
telegram_id?: number | null; telegram_id?: number | null;
/** Active Group Id */ /** Active Group Id */
active_group_id?: number | null; active_group_id?: number | null;
/** Bio */
bio?: string | null;
/** Avatar Url */
avatar_url?: string | null;
}; };
/** ValidationError */ /** ValidationError */
ValidationError: { ValidationError: {
@@ -1634,6 +1735,154 @@ export interface operations {
}; };
}; };
}; };
update_my_profile_api_users_me_profile_patch: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ProfileUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MeRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
upload_my_avatar_api_users_me_avatar_put: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_upload_my_avatar_api_users_me_avatar_put"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MeRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_my_avatar_api_users_me_avatar_delete: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MeRead"];
};
};
};
};
get_user_avatar_api_users__user_id__avatar_get: {
parameters: {
query?: never;
header?: never;
path: {
user_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_user_profile_api_users__user_id__profile_get: {
parameters: {
query?: never;
header?: never;
path: {
user_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PublicProfile"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
my_stats_api_users_me_stats_get: { my_stats_api_users_me_stats_get: {
parameters: { parameters: {
query?: never; query?: never;
+4
View File
@@ -9,6 +9,8 @@ import { GroupSettingsPage } from "../pages/GroupSettingsPage";
import { HomePage } from "../pages/HomePage"; import { HomePage } from "../pages/HomePage";
import { LoginPage } from "../pages/LoginPage"; import { LoginPage } from "../pages/LoginPage";
import { MatchDetailPage } from "../pages/MatchDetailPage"; import { MatchDetailPage } from "../pages/MatchDetailPage";
import { OverallStatsPage } from "../pages/OverallStatsPage";
import { PublicProfilePage } from "../pages/PublicProfilePage";
import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage"; import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage";
import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage"; import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage";
import { AdminLayout } from "../pages/admin/AdminLayout"; import { AdminLayout } from "../pages/admin/AdminLayout";
@@ -37,6 +39,8 @@ export const router = createBrowserRouter([
), ),
}, },
{ path: "match/:matchId", element: <MatchDetailPage /> }, { path: "match/:matchId", element: <MatchDetailPage /> },
{ path: "stats", element: <OverallStatsPage /> },
{ path: "u/:userId", element: <PublicProfilePage /> },
{ path: "group", element: <GroupPage /> }, { path: "group", element: <GroupPage /> },
{ {
path: "group/settings", path: "group/settings",
+4 -1
View File
@@ -8,12 +8,15 @@ const TITLES: Record<string, string> = {
"/group/settings": "Настройки группы", "/group/settings": "Настройки группы",
"/account": "Аккаунт", "/account": "Аккаунт",
"/match/new": "Новая партия", "/match/new": "Новая партия",
"/stats": "Общая статистика",
}; };
export function AppShell() { export function AppShell() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const title = TITLES[location.pathname] ?? "Forbidden Stars"; const title =
TITLES[location.pathname] ??
(location.pathname.startsWith("/u/") ? "Профиль игрока" : "Forbidden Stars");
const isHome = location.pathname === "/"; const isHome = location.pathname === "/";
return ( return (
+52
View File
@@ -0,0 +1,52 @@
import type { CSSProperties } from "react";
/**
* Чисто презентационный аватар: картинка по url или инициал ника как заглушка.
* Используется и в своём, и в чужом профиле, и в списках (общий примитив без логики).
*/
export function Avatar({
url,
nickname,
size = 40,
}: {
url?: string | null;
nickname: string;
size?: number;
}) {
const box: CSSProperties = {
width: size,
height: size,
borderRadius: "50%",
flexShrink: 0,
};
if (url) {
return (
<img
src={url}
alt={nickname}
width={size}
height={size}
style={{ ...box, objectFit: "cover", border: "1px solid var(--border)" }}
/>
);
}
const initial = (nickname.trim()[0] ?? "?").toUpperCase();
return (
<div
aria-hidden
style={{
...box,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--surface-2)",
border: "1px solid var(--border)",
color: "var(--text-muted)",
fontWeight: 700,
fontSize: size * 0.42,
}}
>
{initial}
</div>
);
}
+12 -1
View File
@@ -1,4 +1,7 @@
import { Link } from "react-router-dom";
import type { LeaderboardEntry } from "../domain/types"; import type { LeaderboardEntry } from "../domain/types";
import { Avatar } from "./Avatar";
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)}%`;
@@ -10,8 +13,16 @@ function Row({ entry }: { entry: LeaderboardEntry }) {
<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>
<Avatar url={entry.avatar_url} nickname={entry.nickname} size={32} />
<div> <div>
<div style={{ fontWeight: 600 }}>{entry.nickname}</div> <Link
to={`/u/${entry.user_id}`}
// stopPropagation: ник кликабелен даже когда вся карточка-обёртка кликабельна.
onClick={(e) => e.stopPropagation()}
style={{ fontWeight: 600, color: "inherit" }}
>
{entry.nickname}
</Link>
<div className="lb-metrics"> <div className="lb-metrics">
<span>Игр: {entry.games}</span> <span>Игр: {entry.games}</span>
<span>Побед: {entry.wins}</span> <span>Побед: {entry.wins}</span>
+8 -2
View File
@@ -4,11 +4,17 @@ function pct(v: number | null | undefined): string {
return v == null ? "—" : `${Math.round(v * 100)}%`; return v == null ? "—" : `${Math.round(v * 100)}%`;
} }
export function ProfileStatsCard({ stats }: { stats: ProfileStats }) { export function ProfileStatsCard({
stats,
title = "Моя статистика",
}: {
stats: ProfileStats;
title?: string;
}) {
const o = stats.overall; const o = stats.overall;
return ( return (
<div className="card"> <div className="card">
<h3>Моя статистика</h3> <h3>{title}</h3>
<div className="row-between"> <div className="row-between">
<div>Игр</div> <div>Игр</div>
<b>{o.games}</b> <b>{o.games}</b>
+1
View File
@@ -15,6 +15,7 @@ export type MatchListItem = S["MatchListItem"];
export type Leaderboard = S["Leaderboard"]; export type Leaderboard = S["Leaderboard"];
export type LeaderboardEntry = S["LeaderboardEntry"]; export type LeaderboardEntry = S["LeaderboardEntry"];
export type ProfileStats = S["ProfileStats"]; export type ProfileStats = S["ProfileStats"];
export type PublicProfile = S["PublicProfile"];
export type GroupStats = S["GroupStats"]; export type GroupStats = S["GroupStats"];
export type HomeResponse = S["HomeResponse"]; export type HomeResponse = S["HomeResponse"];
export type HomeInProgressMatch = S["HomeInProgressMatch"]; export type HomeInProgressMatch = S["HomeInProgressMatch"];
+100 -1
View File
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, unwrap } from "../api/client"; import { ApiError, api, unwrap } from "../api/client";
import { qk } from "../api/queryKeys"; import { qk } from "../api/queryKeys";
import type { AuthConfig, Me } from "../domain/types"; import type { AuthConfig, Me } from "../domain/types";
@@ -104,6 +104,105 @@ export function useUpdateNickname() {
}); });
} }
// «О себе».
export function useUpdateBio() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (bio: string): Promise<Me> =>
unwrap(await api.PATCH("/api/users/me/profile", { body: { bio } })),
onSuccess: (me) => {
qc.setQueryData(qk.me, me);
qc.invalidateQueries({ queryKey: qk.home });
},
});
}
// Аватар. Картинку уменьшаем на клиенте (≤512px) и грузим multipart'ом отдельным
// fetch (openapi-fetch неудобен для файлов); CSRF-токен из cookie ставим вручную.
function readCsrfToken(): string | null {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
return m ? decodeURIComponent(m[1]) : null;
}
async function resizeImage(file: File, max = 512): Promise<Blob> {
try {
const dataUrl = await new Promise<string>((res, rej) => {
const fr = new FileReader();
fr.onload = () => res(fr.result as string);
fr.onerror = () => rej(fr.error);
fr.readAsDataURL(file);
});
const img = await new Promise<HTMLImageElement>((res, rej) => {
const i = new Image();
i.onload = () => res(i);
i.onerror = () => rej(new Error("image load failed"));
i.src = dataUrl;
});
let { width, height } = img;
if (width > max || height > max) {
const scale = Math.min(max / width, max / height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) return file;
ctx.drawImage(img, 0, 0, width, height);
const blob = await new Promise<Blob | null>((res) =>
canvas.toBlob(res, "image/jpeg", 0.85),
);
return blob ?? file;
} catch {
return file; // не вышло уменьшить — отправим как есть (сервер проверит тип/размер)
}
}
export function useUploadAvatar() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (file: File): Promise<Me> => {
const blob = await resizeImage(file);
const form = new FormData();
form.append("file", blob, "avatar.jpg");
const csrf = readCsrfToken();
const base = import.meta.env.VITE_API_BASE_URL || "";
const r = await fetch(`${base}/api/users/me/avatar`, {
method: "PUT",
body: form,
credentials: "include",
headers: csrf ? { "X-CSRF-Token": csrf } : {},
});
if (!r.ok) {
let env: { code?: string; message?: string; details?: unknown } | undefined;
try {
env = ((await r.json()) as { error?: typeof env }).error;
} catch {
/* тело без JSON */
}
throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status, env?.details);
}
return (await r.json()) as Me;
},
onSuccess: (me) => {
qc.setQueryData(qk.me, me);
qc.invalidateQueries();
},
});
}
export function useDeleteAvatar() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (): Promise<Me> => unwrap(await api.DELETE("/api/users/me/avatar")),
onSuccess: (me) => {
qc.setQueryData(qk.me, me);
qc.invalidateQueries();
},
});
}
export function useSetActiveGroup() { export function useSetActiveGroup() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
+13
View File
@@ -24,3 +24,16 @@ export function useMyStats() {
queryFn: async () => unwrap(await api.GET("/api/users/me/stats")), queryFn: async () => unwrap(await api.GET("/api/users/me/stats")),
}); });
} }
// Публичный профиль другого игрока (read-only).
export function usePublicProfile(userId: number) {
return useQuery({
queryKey: qk.publicProfile(userId),
queryFn: async () =>
unwrap(
await api.GET("/api/users/{user_id}/profile", {
params: { path: { user_id: userId } },
}),
),
});
}
+103 -11
View File
@@ -1,26 +1,42 @@
import { useState } from "react"; import { useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
import { Avatar } from "../components/Avatar";
import { GroupSwitcher } from "../components/GroupSwitcher"; import { GroupSwitcher } from "../components/GroupSwitcher";
import { ProfileStatsCard } from "../components/ProfileStatsCard"; import { ProfileStatsCard } from "../components/ProfileStatsCard";
import { Spinner } from "../components/Spinner"; import { Spinner } from "../components/Spinner";
import { useToast } from "../context/ToastContext"; import { useToast } from "../context/ToastContext";
import { useLogout, useMe, useUpdateNickname } from "../hooks/auth"; import {
useDeleteAvatar,
useLogout,
useMe,
useUpdateBio,
useUpdateNickname,
useUploadAvatar,
} from "../hooks/auth";
import { useMyStats } from "../hooks/stats"; import { useMyStats } from "../hooks/stats";
export function AccountPage() { export function AccountPage() {
const { data: me, isLoading } = useMe(); const { data: me, isLoading } = useMe();
const { data: stats } = useMyStats(); const { data: stats } = useMyStats();
const updateNick = useUpdateNickname(); const updateNick = useUpdateNickname();
const updateBio = useUpdateBio();
const uploadAvatar = useUploadAvatar();
const deleteAvatar = useDeleteAvatar();
const logout = useLogout(); const logout = useLogout();
const toast = useToast(); const toast = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
const fileRef = useRef<HTMLInputElement>(null);
const [nickname, setNickname] = useState(""); const [nickname, setNickname] = useState("");
const [bio, setBio] = useState<string | null>(null);
if (isLoading || !me) return <Spinner />; if (isLoading || !me) return <Spinner />;
const save = async () => { // bio редактируется локально; до первого ввода берём значение из профиля.
const bioValue = bio ?? me.bio ?? "";
const saveNick = async () => {
const nick = nickname.trim(); const nick = nickname.trim();
if (!nick) return; if (!nick) return;
try { try {
@@ -32,6 +48,36 @@ export function AccountPage() {
} }
}; };
const saveBio = async () => {
try {
await updateBio.mutateAsync(bioValue.trim());
toast.show("Сохранено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const onPickFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = ""; // позволяем выбрать тот же файл повторно
if (!file) return;
try {
await uploadAvatar.mutateAsync(file);
toast.show("Аватар обновлён");
} catch (err) {
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
}
};
const removeAvatar = async () => {
try {
await deleteAvatar.mutateAsync();
toast.show("Аватар удалён");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const doLogout = async () => { const doLogout = async () => {
await logout.mutateAsync().catch(() => {}); await logout.mutateAsync().catch(() => {});
navigate("/login", { replace: true }); navigate("/login", { replace: true });
@@ -41,15 +87,64 @@ export function AccountPage() {
<div> <div>
<div className="card"> <div className="card">
<h3>Профиль</h3> <h3>Профиль</h3>
<div className="row-between"> <div className="row" style={{ gap: 14 }}>
<span className="muted">Никнейм</span> <Avatar url={me.avatar_url} nickname={me.nickname} size={64} />
<b>{me.nickname}</b> <div style={{ flex: 1 }}>
<b style={{ fontSize: 18 }}>{me.nickname}</b>
<div className="row" style={{ gap: 8, marginTop: 8 }}>
<button
className="btn small"
onClick={() => fileRef.current?.click()}
disabled={uploadAvatar.isPending}
>
{me.avatar_url ? "Сменить аватар" : "Загрузить аватар"}
</button>
{me.avatar_url && (
<button
className="btn btn-ghost btn-danger small"
onClick={removeAvatar}
disabled={deleteAvatar.isPending}
>
Удалить
</button>
)}
</div> </div>
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/webp"
style={{ display: "none" }}
onChange={onPickFile}
/>
</div>
</div>
<div className="field" style={{ marginTop: 14 }}>
<label className="label">О себе</label>
<textarea
rows={3}
maxLength={500}
placeholder="Пара слов о себе…"
value={bioValue}
onChange={(e) => setBio(e.target.value)}
/>
<div className="row-between" style={{ marginTop: 6 }}>
<span className="muted small">{bioValue.length}/500</span>
<button
className="btn btn-primary small"
onClick={saveBio}
disabled={updateBio.isPending || bioValue.trim() === (me.bio ?? "")}
>
Сохранить
</button>
</div>
</div>
<div className="field" style={{ marginTop: 10 }}> <div className="field" style={{ marginTop: 10 }}>
<label className="label">Сменить никнейм</label> <label className="label">Сменить никнейм</label>
<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={save} disabled={updateNick.isPending}> <button className="btn btn-primary" onClick={saveNick} disabled={updateNick.isPending}>
ОК ОК
</button> </button>
</div> </div>
@@ -65,10 +160,7 @@ export function AccountPage() {
</button> </button>
<p className="center" style={{ marginTop: 24 }}> <p className="center" style={{ marginTop: 24 }}>
<button <button className="btn btn-ghost small muted" onClick={() => navigate("/admin/login")}>
className="btn btn-ghost small muted"
onClick={() => navigate("/admin/login")}
>
Вход для администратора Вход для администратора
</button> </button>
</p> </p>
+12 -2
View File
@@ -1,3 +1,5 @@
import { useNavigate } from "react-router-dom";
import { InProgressMatches } from "../components/InProgressMatches"; import { InProgressMatches } from "../components/InProgressMatches";
import { Leaderboard } from "../components/Leaderboard"; import { Leaderboard } from "../components/Leaderboard";
import { ProfileStatsCard } from "../components/ProfileStatsCard"; import { ProfileStatsCard } from "../components/ProfileStatsCard";
@@ -6,16 +8,24 @@ import { useHome } from "../hooks/stats";
export function HomePage() { export function HomePage() {
const { data, isLoading, isError } = useHome(); const { data, isLoading, isError } = useHome();
const navigate = useNavigate();
if (isLoading) return <Spinner />; if (isLoading) return <Spinner />;
if (isError || !data) return <div className="muted">Не удалось загрузить данные.</div>; if (isError || !data) return <div className="muted">Не удалось загрузить данные.</div>;
return ( return (
<div> <div>
<div className="card"> {/* Карточка кликабельна целиком → общая статистика; клик по нику внутри
<h3>Топ игроков</h3> ведёт в профиль (Leaderboard останавливает всплытие на ссылках ников). */}
<div className="card" style={{ cursor: "pointer" }} onClick={() => navigate("/stats")}>
<div className="row-between">
<h3 style={{ margin: 0 }}>Топ игроков</h3>
<span className="muted small">Вся статистика →</span>
</div>
<div style={{ marginTop: 10 }}>
<Leaderboard entries={data.leaderboard} provisional={data.provisional} /> <Leaderboard entries={data.leaderboard} provisional={data.provisional} />
</div> </div>
</div>
<ProfileStatsCard stats={data.profile} /> <ProfileStatsCard stats={data.profile} />
+92
View File
@@ -0,0 +1,92 @@
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Avatar } from "../components/Avatar";
import { Spinner } from "../components/Spinner";
import type { LeaderboardEntry } from "../domain/types";
import { useLeaderboard } from "../hooks/stats";
type SortKey = "rank" | "games" | "wins" | "win_rate" | "score";
function pct(v: number | null | undefined): string {
return v == null ? "—" : `${Math.round(v * 100)}%`;
}
export function OverallStatsPage() {
const { data, isLoading, isError } = useLeaderboard(200);
const [q, setQ] = useState("");
const [sortKey, setSortKey] = useState<SortKey>("rank");
const [dir, setDir] = useState<1 | -1>(1); // 1 — по возрастанию
const toggleSort = (k: SortKey) => {
if (k === sortKey) setDir((d) => (d === 1 ? -1 : 1));
else {
setSortKey(k);
setDir(k === "rank" ? 1 : -1); // для метрик по умолчанию «лучшие сверху»
}
};
const filterSort = useMemo(() => {
const needle = q.trim().toLowerCase();
const num = (e: LeaderboardEntry) => Number(e[sortKey] ?? 0);
return (list: LeaderboardEntry[]) =>
list
.filter((e) => e.nickname.toLowerCase().includes(needle))
.sort((a, b) => (num(a) - num(b)) * dir);
}, [q, sortKey, dir]);
if (isLoading) return <Spinner />;
if (isError || !data) return <div className="muted">Не удалось загрузить статистику.</div>;
const entries = filterSort(data.entries);
const provisional = filterSort(data.provisional);
const arrow = (k: SortKey) => (k === sortKey ? (dir === 1 ? " ↑" : " ↓") : "");
const row = (e: LeaderboardEntry) => (
<Link key={e.user_id} to={`/u/${e.user_id}`} className="st-row" style={{ color: "inherit" }}>
<span className={"st-num" + (e.rank && e.rank <= 3 ? " gold" : "")}>{e.rank ?? "—"}</span>
<span className="st-player">
<Avatar url={e.avatar_url} nickname={e.nickname} size={28} />
<span className="nick">{e.nickname}</span>
</span>
<span className="st-num">{e.games}</span>
<span className="st-num">{e.wins}</span>
<span className="st-num">{pct(e.win_rate)}</span>
<span className="st-num lb-score">{e.score ?? "—"}</span>
</Link>
);
return (
<div>
<div className="card">
<h3>Общая статистика</h3>
<input placeholder="Поиск по нику…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="card">
<div className="st-head">
<button onClick={() => toggleSort("rank")}>#{arrow("rank")}</button>
<span>Игрок</span>
<button onClick={() => toggleSort("games")}>Игр{arrow("games")}</button>
<button onClick={() => toggleSort("wins")}>Поб{arrow("wins")}</button>
<button onClick={() => toggleSort("win_rate")}>WR{arrow("win_rate")}</button>
<button onClick={() => toggleSort("score")}>Очки{arrow("score")}</button>
</div>
{entries.length === 0 ? (
<div className="muted small" style={{ paddingTop: 8 }}>
Никого не найдено.
</div>
) : (
entries.map(row)
)}
</div>
{provisional.length > 0 && (
<div className="card">
<div className="label">Новички (меньше {data.min_games} игр)</div>
{provisional.map(row)}
</div>
)}
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { useParams } from "react-router-dom";
import { Avatar } from "../components/Avatar";
import { ProfileStatsCard } from "../components/ProfileStatsCard";
import { Spinner } from "../components/Spinner";
import { usePublicProfile } from "../hooks/stats";
/**
* Просмотр ЧУЖОГО профиля — отдельный read-only компонент (намеренно изолирован от
* редактируемого AccountPage: здесь нет никаких контролов правки, только просмотр).
*/
export function PublicProfilePage() {
const { userId } = useParams();
const id = Number(userId);
const { data, isLoading, isError } = usePublicProfile(id);
if (isLoading) return <Spinner />;
if (isError || !data) return <div className="muted">Профиль не найден.</div>;
return (
<div>
<div className="card">
<div className="row" style={{ gap: 14 }}>
<Avatar url={data.avatar_url} nickname={data.nickname} size={64} />
<div style={{ flex: 1 }}>
<b style={{ fontSize: 18 }}>{data.nickname}</b>
{data.bio ? (
<div className="muted" style={{ marginTop: 6, whiteSpace: "pre-wrap" }}>
{data.bio}
</div>
) : (
<div className="muted small" style={{ marginTop: 6 }}>
Игрок пока не рассказал о себе.
</div>
)}
</div>
</div>
</div>
<ProfileStatsCard stats={data.stats} title="Статистика" />
</div>
);
}
+21 -1
View File
@@ -90,7 +90,7 @@
/* Таблицы / списки лидерборда */ /* Таблицы / списки лидерборда */
.lb-row { .lb-row {
display: grid; display: grid;
grid-template-columns: 28px 1fr auto; grid-template-columns: 28px 32px 1fr auto;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 8px 4px; padding: 8px 4px;
@@ -102,6 +102,26 @@
.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); }
/* Таблица общей статистики (сортируемая, кликабельные строки) */
.st-row, .st-head {
display: grid;
grid-template-columns: 26px 1fr 40px 40px 48px 52px;
align-items: center;
gap: 8px;
padding: 8px 2px;
border-bottom: 1px solid var(--border);
}
.st-head { color: var(--text-muted); font-size: 12px; }
.st-head button {
background: none; border: none; color: inherit; font: inherit;
cursor: pointer; padding: 0; text-align: right;
}
.st-row:last-child { border-bottom: none; }
.st-num { text-align: right; font-variant-numeric: tabular-nums; font-size: 14px; }
.st-num.gold { color: var(--gold); font-weight: 700; }
.st-player { display: flex; align-items: center; gap: 8px; min-width: 0; font-weight: 600; }
.st-player .nick { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.participant-row { .participant-row {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;