v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика

This commit is contained in:
2026-06-16 17:41:06 +03:00
parent 6ab74f01aa
commit 56b5d09a4d
123 changed files with 13572 additions and 21 deletions
+165
View File
@@ -0,0 +1,165 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, unwrap } from "../api/client";
import { qk } from "../api/queryKeys";
import type { AdminMe, MatchUpdate } from "../domain/types";
export function useAdminMe() {
return useQuery({
queryKey: qk.adminMe,
queryFn: async (): Promise<AdminMe | null> => {
const r = await api.GET("/api/admin/me");
if (r.response.status === 401 || r.response.status === 403) return null;
return unwrap(r);
},
});
}
export function useAdminLogin() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: { username: string; password: string }) =>
unwrap(await api.POST("/api/admin/auth/login", { body })),
onSuccess: (me) => qc.setQueryData(qk.adminMe, me),
});
}
export function useAdminLogout() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => unwrap(await api.POST("/api/admin/auth/logout")),
onSuccess: () => qc.setQueryData(qk.adminMe, null),
});
}
export function useAdminUsers(query?: string) {
return useQuery({
queryKey: [...qk.adminUsers, query ?? ""],
queryFn: async () =>
unwrap(
await api.GET("/api/admin/users", {
params: { query: query ? { query } : {} },
}),
),
});
}
export function useAdminGroups() {
return useQuery({
queryKey: qk.adminGroups,
queryFn: async () => unwrap(await api.GET("/api/admin/groups")),
});
}
export interface AdminMatchFilters {
group_id?: number | null;
user_id?: number | null;
faction_id?: number | null;
}
export function useAdminMatches(filters: AdminMatchFilters = {}) {
const query: Record<string, number> = {};
if (filters.group_id != null) query.group_id = filters.group_id;
if (filters.user_id != null) query.user_id = filters.user_id;
if (filters.faction_id != null) query.faction_id = filters.faction_id;
return useQuery({
queryKey: [...qk.adminMatches, query],
queryFn: async () =>
unwrap(await api.GET("/api/admin/matches", { params: { query } })),
});
}
export function useAdminMatch(matchId: number | null) {
return useQuery({
queryKey: ["adminMatch", matchId],
enabled: matchId != null,
queryFn: async () =>
unwrap(
await api.GET("/api/admin/matches/{match_id}", {
params: { path: { match_id: matchId as number } },
}),
),
});
}
export function useAdminUpdateMatch() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (args: { matchId: number; body: MatchUpdate }) =>
unwrap(
await api.PATCH("/api/admin/matches/{match_id}", {
params: { path: { match_id: args.matchId } },
body: args.body,
}),
),
onSuccess: (_d, args) => {
qc.invalidateQueries({ queryKey: qk.adminMatches });
qc.invalidateQueries({ queryKey: ["adminMatch", args.matchId] });
},
});
}
export function useAdminFactions() {
return useQuery({
queryKey: ["adminFactions"],
queryFn: async () => unwrap(await api.GET("/api/admin/factions")),
});
}
export function useAdminRenameFaction() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (args: { factionId: number; name_ru: string }) =>
unwrap(
await api.PATCH("/api/admin/factions/{faction_id}", {
params: { path: { faction_id: args.factionId } },
body: { name_ru: args.name_ru },
}),
),
onSuccess: () => qc.invalidateQueries({ queryKey: ["adminFactions"] }),
});
}
export function useAdminLogs(filters?: { action?: string; entity_type?: string }) {
return useQuery({
queryKey: [...qk.adminLogs, filters?.action ?? "", filters?.entity_type ?? ""],
queryFn: async () =>
unwrap(
await api.GET("/api/admin/audit-logs", {
params: {
query: {
...(filters?.action ? { action: filters.action } : {}),
...(filters?.entity_type ? { entity_type: filters.entity_type } : {}),
},
},
}),
),
});
}
export function useAdminDeleteMatch() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (matchId: number) =>
unwrap(
await api.DELETE("/api/admin/matches/{match_id}", {
params: { path: { match_id: matchId } },
}),
),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminMatches }),
});
}
export function useAdminUpdateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (args: { userId: number; nickname?: string; is_active?: boolean }) =>
unwrap(
await api.PATCH("/api/admin/users/{user_id}", {
params: { path: { user_id: args.userId } },
body: { nickname: args.nickname, is_active: args.is_active },
}),
),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminUsers }),
});
}
+101
View File
@@ -0,0 +1,101 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { 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.
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();
},
});
}
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 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 });
},
});
}
+123
View File
@@ -0,0 +1,123 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, unwrap } from "../api/client";
import { qk } from "../api/queryKeys";
export function useMyGroups() {
return useQuery({
queryKey: qk.groups,
queryFn: async () => unwrap(await api.GET("/api/groups")),
});
}
export function useGroup(groupId: number | null) {
return useQuery({
queryKey: groupId ? qk.group(groupId) : ["group", "none"],
enabled: groupId != null,
queryFn: async () =>
unwrap(
await api.GET("/api/groups/{group_id}", {
params: { path: { group_id: groupId as number } },
}),
),
});
}
export function useGroupMembers(groupId: number | null) {
return useQuery({
queryKey: groupId ? qk.groupMembers(groupId) : ["group", "none", "members"],
enabled: groupId != null,
queryFn: async () =>
unwrap(
await api.GET("/api/groups/{group_id}/members", {
params: { path: { group_id: groupId as number } },
}),
),
});
}
export function useGroupStats(groupId: number | null) {
return useQuery({
queryKey: groupId ? qk.groupStats(groupId) : ["group", "none", "stats"],
enabled: groupId != null,
queryFn: async () =>
unwrap(
await api.GET("/api/groups/{group_id}/stats", {
params: { path: { group_id: groupId as number } },
}),
),
});
}
export function useGroupMatches(groupId: number | null, limit = 20, offset = 0) {
return useQuery({
queryKey: groupId ? [...qk.groupMatches(groupId), limit, offset] : ["group", "none", "matches"],
enabled: groupId != null,
queryFn: async () =>
unwrap(
await api.GET("/api/groups/{group_id}/matches", {
params: { path: { group_id: groupId as number }, query: { limit, offset } },
}),
),
});
}
export function useCreateGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: { name: string; expansion_ids: number[] }) =>
unwrap(await api.POST("/api/groups", { body })),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.groups });
qc.invalidateQueries({ queryKey: qk.me });
qc.invalidateQueries({ queryKey: qk.home });
},
});
}
export function useSetGroupExpansions(groupId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (expansion_ids: number[]) =>
unwrap(
await api.PUT("/api/groups/{group_id}/expansions", {
params: { path: { group_id: groupId } },
body: { expansion_ids },
}),
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.group(groupId) });
qc.invalidateQueries({ queryKey: qk.groupFactions(groupId) });
},
});
}
export function useAddMember(groupId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (nickname: string) =>
unwrap(
await api.POST("/api/groups/{group_id}/members", {
params: { path: { group_id: groupId } },
body: { nickname },
}),
),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.groupMembers(groupId) }),
});
}
export function useRemoveMember(groupId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (userId: number) =>
unwrap(
await api.DELETE("/api/groups/{group_id}/members/{user_id}", {
params: { path: { group_id: groupId, user_id: userId } },
}),
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.groupMembers(groupId) });
qc.invalidateQueries({ queryKey: qk.me });
},
});
}
+75
View File
@@ -0,0 +1,75 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, unwrap } from "../api/client";
import { qk } from "../api/queryKeys";
import type { FactionRead, MatchCreate, MatchFinish, MatchRead } from "../domain/types";
export function useMatch(matchId: number | null) {
return useQuery({
queryKey: matchId ? qk.match(matchId) : ["match", "none"],
enabled: matchId != null,
queryFn: async () =>
unwrap(
await api.GET("/api/matches/{match_id}", {
params: { path: { match_id: matchId as number } },
}),
),
});
}
export function useRandomizeFaction() {
return useMutation({
mutationFn: async (args: { group_id: number; exclude_faction_ids: number[] }): Promise<FactionRead> => {
const res = unwrap(await api.POST("/api/matches/randomize-faction", { body: args }));
return res.faction;
},
});
}
/** Этап 1: старт партии (игроки + фракции, без мест). */
export function useStartMatch() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: MatchCreate): Promise<MatchRead> =>
unwrap(await api.POST("/api/matches", { body })),
onSuccess: (m) => {
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
qc.invalidateQueries({ queryKey: qk.home });
},
});
}
/** Этап 2: завершение партии (места, причина победы). */
export function useFinishMatch() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (args: { matchId: number; body: MatchFinish }): Promise<MatchRead> =>
unwrap(
await api.POST("/api/matches/{match_id}/finish", {
params: { path: { match_id: args.matchId } },
body: args.body,
}),
),
onSuccess: (m) => {
qc.invalidateQueries({ queryKey: qk.match(m.id) });
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) });
qc.invalidateQueries({ queryKey: qk.leaderboard });
qc.invalidateQueries({ queryKey: qk.home });
qc.invalidateQueries({ queryKey: qk.myStats });
},
});
}
export function useDeleteMatch() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (matchId: number) =>
unwrap(
await api.DELETE("/api/matches/{match_id}", {
params: { path: { match_id: matchId } },
}),
),
onSuccess: () => qc.invalidateQueries(),
});
}
+24
View File
@@ -0,0 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import { api, unwrap } from "../api/client";
import { qk } from "../api/queryKeys";
export function useExpansions() {
return useQuery({
queryKey: qk.expansions,
queryFn: async () => unwrap(await api.GET("/api/expansions")),
});
}
export function useGroupFactions(groupId: number | null) {
return useQuery({
queryKey: groupId ? qk.groupFactions(groupId) : ["group", "none", "factions"],
enabled: groupId != null,
queryFn: async () =>
unwrap(
await api.GET("/api/groups/{group_id}/factions", {
params: { path: { group_id: groupId as number } },
}),
),
});
}
+26
View File
@@ -0,0 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import { api, unwrap } from "../api/client";
import { qk } from "../api/queryKeys";
export function useHome() {
return useQuery({
queryKey: qk.home,
queryFn: async () => unwrap(await api.GET("/api/home")),
});
}
export function useLeaderboard(limit = 50) {
return useQuery({
queryKey: [...qk.leaderboard, limit],
queryFn: async () =>
unwrap(await api.GET("/api/stats/leaderboard", { params: { query: { limit } } })),
});
}
export function useMyStats() {
return useQuery({
queryKey: qk.myStats,
queryFn: async () => unwrap(await api.GET("/api/users/me/stats")),
});
}