217 lines
7.1 KiB
TypeScript
217 lines
7.1 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
|
||
import { ApiError, api, unwrap } from "../api/client";
|
||
import { qk } from "../api/queryKeys";
|
||
import type { AuthConfig, Me } from "../domain/types";
|
||
|
||
export function useMe() {
|
||
return useQuery({
|
||
queryKey: qk.me,
|
||
queryFn: async (): Promise<Me | null> => {
|
||
const r = await api.GET("/api/users/me");
|
||
if (r.response.status === 401) return null;
|
||
return unwrap(r);
|
||
},
|
||
});
|
||
}
|
||
|
||
export function useAuthConfig() {
|
||
return useQuery({
|
||
queryKey: qk.authConfig,
|
||
queryFn: async (): Promise<AuthConfig> => unwrap(await api.GET("/api/auth/config")),
|
||
});
|
||
}
|
||
|
||
export function useDevUsers(enabled: boolean) {
|
||
return useQuery({
|
||
queryKey: qk.devUsers,
|
||
enabled,
|
||
queryFn: async () => unwrap(await api.GET("/api/auth/dev/users")),
|
||
});
|
||
}
|
||
|
||
// DEV-вход по нику (только в деве; в прод-бэкенде эндпоинта нет).
|
||
export function useStubLogin() {
|
||
const qc = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: async (nickname: string) =>
|
||
unwrap(await api.POST("/api/auth/dev/login", { body: { nickname } })),
|
||
onSuccess: (me) => {
|
||
qc.setQueryData(qk.me, me);
|
||
qc.invalidateQueries();
|
||
},
|
||
});
|
||
}
|
||
|
||
// Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC.
|
||
// При первом входе, если тег занят, бэкенд отдаёт 409 TELEGRAM_NICKNAME_REQUIRED —
|
||
// тогда вызывается useTelegramRegister с выбранным ником (тот же payload виджета).
|
||
export function useTelegramLogin() {
|
||
const qc = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: async (payload: Record<string, unknown>) =>
|
||
unwrap(await api.POST("/api/auth/telegram", { body: payload as never })),
|
||
onSuccess: (me) => {
|
||
qc.setQueryData(qk.me, me);
|
||
qc.invalidateQueries();
|
||
},
|
||
});
|
||
}
|
||
|
||
// Регистрация через Telegram с выбранным ником (когда тег занят/некорректен).
|
||
// payload — те же данные виджета + nickname; подпись проверяется заново на сервере.
|
||
export function useTelegramRegister() {
|
||
const qc = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: async (payload: Record<string, unknown>) =>
|
||
unwrap(await api.POST("/api/auth/telegram/register", { body: payload as never })),
|
||
onSuccess: (me) => {
|
||
qc.setQueryData(qk.me, me);
|
||
qc.invalidateQueries();
|
||
},
|
||
});
|
||
}
|
||
|
||
export function useCreateDevUser() {
|
||
const qc = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: async (nickname: string) =>
|
||
unwrap(await api.POST("/api/auth/dev/users", { body: { nickname } })),
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.devUsers }),
|
||
});
|
||
}
|
||
|
||
export function useLogout() {
|
||
const qc = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: async () => unwrap(await api.POST("/api/auth/logout")),
|
||
onSuccess: () => {
|
||
qc.setQueryData(qk.me, null);
|
||
qc.clear();
|
||
},
|
||
});
|
||
}
|
||
|
||
export function useUpdateNickname() {
|
||
const qc = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: async (nickname: string) =>
|
||
unwrap(await api.PATCH("/api/users/me", { body: { nickname } })),
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: qk.me });
|
||
qc.invalidateQueries({ queryKey: qk.home });
|
||
},
|
||
});
|
||
}
|
||
|
||
// «О себе».
|
||
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({
|
||
mutationFn: async (group_id: number | null) =>
|
||
unwrap(await api.PUT("/api/users/me/active-group", { body: { group_id } })),
|
||
onSuccess: (me) => {
|
||
qc.setQueryData(qk.me, me);
|
||
qc.invalidateQueries({ queryKey: qk.home });
|
||
},
|
||
});
|
||
}
|