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

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
+100 -1
View File
@@ -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({
+13
View File
@@ -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 } },
}),
),
});
}