Кастомизация профиля (о себе, аватар) и просмотр профилей других игроков + общая статистика
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ export const qk = {
|
||||
expansions: ["expansions"] as const,
|
||||
factions: ["factions"] as const,
|
||||
myStats: ["myStats"] as const,
|
||||
publicProfile: (id: number) => ["publicProfile", id] as const,
|
||||
groups: ["groups"] as const,
|
||||
group: (id: number) => ["group", id] as const,
|
||||
groupFactions: (id: number) => ["group", id, "factions"] as const,
|
||||
|
||||
Vendored
+249
@@ -105,6 +105,75 @@ export interface paths {
|
||||
patch: operations["update_me_api_users_me_patch"];
|
||||
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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -843,6 +912,11 @@ export interface components {
|
||||
/** Telegram Bot Username */
|
||||
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: {
|
||||
/** Nickname */
|
||||
@@ -1097,6 +1171,8 @@ export interface components {
|
||||
nickname: string;
|
||||
/** Rank */
|
||||
rank?: number | null;
|
||||
/** Avatar Url */
|
||||
avatar_url?: string | null;
|
||||
};
|
||||
/** MatchCreate */
|
||||
MatchCreate: {
|
||||
@@ -1272,6 +1348,10 @@ export interface components {
|
||||
telegram_id?: number | null;
|
||||
/** Active Group Id */
|
||||
active_group_id?: number | null;
|
||||
/** Bio */
|
||||
bio?: string | null;
|
||||
/** Avatar Url */
|
||||
avatar_url?: string | null;
|
||||
/**
|
||||
* Groups
|
||||
* @default []
|
||||
@@ -1363,6 +1443,23 @@ export interface components {
|
||||
*/
|
||||
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: {
|
||||
/** Group Id */
|
||||
@@ -1452,6 +1549,10 @@ export interface components {
|
||||
telegram_id?: number | null;
|
||||
/** Active Group Id */
|
||||
active_group_id?: number | null;
|
||||
/** Bio */
|
||||
bio?: string | null;
|
||||
/** Avatar Url */
|
||||
avatar_url?: string | null;
|
||||
};
|
||||
/** 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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -9,6 +9,8 @@ import { GroupSettingsPage } from "../pages/GroupSettingsPage";
|
||||
import { HomePage } from "../pages/HomePage";
|
||||
import { LoginPage } from "../pages/LoginPage";
|
||||
import { MatchDetailPage } from "../pages/MatchDetailPage";
|
||||
import { OverallStatsPage } from "../pages/OverallStatsPage";
|
||||
import { PublicProfilePage } from "../pages/PublicProfilePage";
|
||||
import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage";
|
||||
import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage";
|
||||
import { AdminLayout } from "../pages/admin/AdminLayout";
|
||||
@@ -37,6 +39,8 @@ export const router = createBrowserRouter([
|
||||
),
|
||||
},
|
||||
{ path: "match/:matchId", element: <MatchDetailPage /> },
|
||||
{ path: "stats", element: <OverallStatsPage /> },
|
||||
{ path: "u/:userId", element: <PublicProfilePage /> },
|
||||
{ path: "group", element: <GroupPage /> },
|
||||
{
|
||||
path: "group/settings",
|
||||
|
||||
@@ -8,12 +8,15 @@ const TITLES: Record<string, string> = {
|
||||
"/group/settings": "Настройки группы",
|
||||
"/account": "Аккаунт",
|
||||
"/match/new": "Новая партия",
|
||||
"/stats": "Общая статистика",
|
||||
};
|
||||
|
||||
export function AppShell() {
|
||||
const location = useLocation();
|
||||
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 === "/";
|
||||
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import type { LeaderboardEntry } from "../domain/types";
|
||||
import { Avatar } from "./Avatar";
|
||||
|
||||
function pct(v: number | null | undefined): string {
|
||||
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" : "")}>
|
||||
{entry.rank ?? "—"}
|
||||
</div>
|
||||
<Avatar url={entry.avatar_url} nickname={entry.nickname} size={32} />
|
||||
<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">
|
||||
<span>Игр: {entry.games}</span>
|
||||
<span>Побед: {entry.wins}</span>
|
||||
|
||||
@@ -4,11 +4,17 @@ function pct(v: number | null | undefined): string {
|
||||
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;
|
||||
return (
|
||||
<div className="card">
|
||||
<h3>Моя статистика</h3>
|
||||
<h3>{title}</h3>
|
||||
<div className="row-between">
|
||||
<div>Игр</div>
|
||||
<b>{o.games}</b>
|
||||
|
||||
@@ -15,6 +15,7 @@ export type MatchListItem = S["MatchListItem"];
|
||||
export type Leaderboard = S["Leaderboard"];
|
||||
export type LeaderboardEntry = S["LeaderboardEntry"];
|
||||
export type ProfileStats = S["ProfileStats"];
|
||||
export type PublicProfile = S["PublicProfile"];
|
||||
export type GroupStats = S["GroupStats"];
|
||||
export type HomeResponse = S["HomeResponse"];
|
||||
export type HomeInProgressMatch = S["HomeInProgressMatch"];
|
||||
|
||||
+100
-1
@@ -1,6 +1,6 @@
|
||||
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 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() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -24,3 +24,16 @@ export function useMyStats() {
|
||||
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 } },
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,26 +1,42 @@
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ApiError } from "../api/client";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { GroupSwitcher } from "../components/GroupSwitcher";
|
||||
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
||||
import { Spinner } from "../components/Spinner";
|
||||
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";
|
||||
|
||||
export function AccountPage() {
|
||||
const { data: me, isLoading } = useMe();
|
||||
const { data: stats } = useMyStats();
|
||||
const updateNick = useUpdateNickname();
|
||||
const updateBio = useUpdateBio();
|
||||
const uploadAvatar = useUploadAvatar();
|
||||
const deleteAvatar = useDeleteAvatar();
|
||||
const logout = useLogout();
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [nickname, setNickname] = useState("");
|
||||
const [bio, setBio] = useState<string | null>(null);
|
||||
|
||||
if (isLoading || !me) return <Spinner />;
|
||||
|
||||
const save = async () => {
|
||||
// bio редактируется локально; до первого ввода берём значение из профиля.
|
||||
const bioValue = bio ?? me.bio ?? "";
|
||||
|
||||
const saveNick = async () => {
|
||||
const nick = nickname.trim();
|
||||
if (!nick) return;
|
||||
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 () => {
|
||||
await logout.mutateAsync().catch(() => {});
|
||||
navigate("/login", { replace: true });
|
||||
@@ -41,15 +87,64 @@ export function AccountPage() {
|
||||
<div>
|
||||
<div className="card">
|
||||
<h3>Профиль</h3>
|
||||
<div className="row-between">
|
||||
<span className="muted">Никнейм</span>
|
||||
<b>{me.nickname}</b>
|
||||
<div className="row" style={{ gap: 14 }}>
|
||||
<Avatar url={me.avatar_url} nickname={me.nickname} size={64} />
|
||||
<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>
|
||||
<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 }}>
|
||||
<label className="label">Сменить никнейм</label>
|
||||
<div className="row">
|
||||
<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>
|
||||
</div>
|
||||
@@ -65,10 +160,7 @@ export function AccountPage() {
|
||||
</button>
|
||||
|
||||
<p className="center" style={{ marginTop: 24 }}>
|
||||
<button
|
||||
className="btn btn-ghost small muted"
|
||||
onClick={() => navigate("/admin/login")}
|
||||
>
|
||||
<button className="btn btn-ghost small muted" onClick={() => navigate("/admin/login")}>
|
||||
Вход для администратора
|
||||
</button>
|
||||
</p>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { InProgressMatches } from "../components/InProgressMatches";
|
||||
import { Leaderboard } from "../components/Leaderboard";
|
||||
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
||||
@@ -6,15 +8,23 @@ import { useHome } from "../hooks/stats";
|
||||
|
||||
export function HomePage() {
|
||||
const { data, isLoading, isError } = useHome();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
if (isError || !data) return <div className="muted">Не удалось загрузить данные.</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
<h3>Топ игроков</h3>
|
||||
<Leaderboard entries={data.leaderboard} provisional={data.provisional} />
|
||||
{/* Карточка кликабельна целиком → общая статистика; клик по нику внутри
|
||||
ведёт в профиль (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} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProfileStatsCard stats={data.profile} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -90,7 +90,7 @@
|
||||
/* Таблицы / списки лидерборда */
|
||||
.lb-row {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr auto;
|
||||
grid-template-columns: 28px 32px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 4px;
|
||||
@@ -102,6 +102,26 @@
|
||||
.lb-metrics { display: flex; gap: 12px; font-size: 13px; color: var(--text-muted); }
|
||||
.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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
Reference in New Issue
Block a user