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 => { 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 => 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) => 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) => 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 => 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 { try { const dataUrl = await new Promise((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((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((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 => { 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 => 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 }); }, }); }