Группы: приглашения вместо авто-добавления (согласиться/отказаться)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 20:07:20 +03:00
co-authored by Claude Opus 4.8
parent 66370c7df4
commit a5c54512a5
20 changed files with 742 additions and 98 deletions
+1
View File
@@ -15,6 +15,7 @@ export const qk = {
groupStats: (id: number) => ["group", id, "stats"] as const,
match: (id: number) => ["match", id] as const,
authConfig: ["authConfig"] as const,
invitations: ["invitations"] as const,
devUsers: ["devUsers"] as const,
adminUsers: ["adminUsers"] as const,
adminGroups: ["adminGroups"] as const,
+166 -4
View File
@@ -305,8 +305,24 @@ export interface paths {
/** List Members */
get: operations["list_members_api_groups__group_id__members_get"];
put?: never;
/** Add Member */
post: operations["add_member_api_groups__group_id__members_post"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/groups/{group_id}/invitations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Invite Member */
post: operations["invite_member_api_groups__group_id__invitations_post"];
delete?: never;
options?: never;
head?: never;
@@ -365,6 +381,57 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/invitations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** My Invitations */
get: operations["my_invitations_api_invitations_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/invitations/{invitation_id}/accept": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Accept Invitation */
post: operations["accept_invitation_api_invitations__invitation_id__accept_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/invitations/{invitation_id}/decline": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Decline Invitation */
post: operations["decline_invitation_api_invitations__invitation_id__decline_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/matches/randomize-faction": {
parameters: {
query?: never;
@@ -1272,6 +1339,19 @@ export interface components {
/** Min Games */
min_games: number;
};
/** InvitationRead */
InvitationRead: {
/** Id */
id: number;
/** Group Id */
group_id: number;
/** Group Name */
group_name: string;
/** Invited By Nickname */
invited_by_nickname?: string | null;
/** Created At */
created_at: string;
};
/** Leaderboard */
Leaderboard: {
/**
@@ -2317,7 +2397,7 @@ export interface operations {
};
};
};
add_member_api_groups__group_id__members_post: {
invite_member_api_groups__group_id__invitations_post: {
parameters: {
query?: never;
header?: never;
@@ -2338,7 +2418,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MemberRead"];
"application/json": components["schemas"]["InvitationRead"];
};
};
/** @description Validation Error */
@@ -2485,6 +2565,88 @@ export interface operations {
};
};
};
my_invitations_api_invitations_get: {
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"]["InvitationRead"][];
};
};
};
};
accept_invitation_api_invitations__invitation_id__accept_post: {
parameters: {
query?: never;
header?: never;
path: {
invitation_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OkResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
decline_invitation_api_invitations__invitation_id__decline_post: {
parameters: {
query?: never;
header?: never;
path: {
invitation_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OkResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
randomize_faction_api_matches_randomize_faction_post: {
parameters: {
query?: never;
@@ -0,0 +1,58 @@
import { ApiError } from "../api/client";
import { useToast } from "../context/ToastContext";
import { useAcceptInvitation, useDeclineInvitation, useMyInvitations } from "../hooks/invitations";
export function GroupInvitations() {
const { data: invitations } = useMyInvitations();
const accept = useAcceptInvitation();
const decline = useDeclineInvitation();
const toast = useToast();
if (!invitations || invitations.length === 0) return null;
const onAccept = async (id: number) => {
try {
await accept.mutateAsync(id);
toast.show("Вы вступили в группу");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const onDecline = async (id: number) => {
try {
await decline.mutateAsync(id);
toast.show("Приглашение отклонено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const busy = accept.isPending || decline.isPending;
return (
<div className="card">
<h3>Приглашения в группы</h3>
<div className="stack">
{invitations.map((inv) => (
<div key={inv.id} className="row-between">
<div>
<b>{inv.group_name}</b>
{inv.invited_by_nickname && (
<div className="muted small">пригласил: {inv.invited_by_nickname}</div>
)}
</div>
<div className="row" style={{ gap: 6 }}>
<button className="btn btn-primary small" onClick={() => onAccept(inv.id)} disabled={busy}>
Согласиться
</button>
<button className="btn btn-ghost small" onClick={() => onDecline(inv.id)} disabled={busy}>
Отказаться
</button>
</div>
</div>
))}
</div>
</div>
);
}
+1
View File
@@ -9,6 +9,7 @@ export type GroupDetail = S["GroupDetail"];
export type ExpansionRead = S["ExpansionRead"];
export type FactionRead = S["FactionRead"];
export type MemberRead = S["MemberRead"];
export type InvitationRead = S["InvitationRead"];
export type MatchRead = S["MatchRead"];
export type MatchList = S["MatchList"];
export type MatchListItem = S["MatchListItem"];
+3 -4
View File
@@ -92,17 +92,16 @@ export function useSetGroupExpansions(groupId: number) {
});
}
export function useAddMember(groupId: number) {
const qc = useQueryClient();
// Приглашение игрока в группу по нику (вместо прямого добавления).
export function useInviteMember(groupId: number) {
return useMutation({
mutationFn: async (nickname: string) =>
unwrap(
await api.POST("/api/groups/{group_id}/members", {
await api.POST("/api/groups/{group_id}/invitations", {
params: { path: { group_id: groupId } },
body: { nickname },
}),
),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.groupMembers(groupId) }),
});
}
+45
View File
@@ -0,0 +1,45 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, unwrap } from "../api/client";
import { qk } from "../api/queryKeys";
export function useMyInvitations() {
return useQuery({
queryKey: qk.invitations,
queryFn: async () => unwrap(await api.GET("/api/invitations")),
});
}
// Принятие/отклонение меняет приглашения, состав групп и активную группу.
function invalidateAll(qc: ReturnType<typeof useQueryClient>) {
qc.invalidateQueries({ queryKey: qk.invitations });
qc.invalidateQueries({ queryKey: qk.me });
qc.invalidateQueries({ queryKey: qk.groups });
qc.invalidateQueries({ queryKey: qk.home });
}
export function useAcceptInvitation() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (invitationId: number) =>
unwrap(
await api.POST("/api/invitations/{invitation_id}/accept", {
params: { path: { invitation_id: invitationId } },
}),
),
onSuccess: () => invalidateAll(qc),
});
}
export function useDeclineInvitation() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (invitationId: number) =>
unwrap(
await api.POST("/api/invitations/{invitation_id}/decline", {
params: { path: { invitation_id: invitationId } },
}),
),
onSuccess: () => invalidateAll(qc),
});
}
+5 -1
View File
@@ -1,6 +1,7 @@
import { useNavigate } from "react-router-dom";
import { CreateGroupForm } from "../components/CreateGroupForm";
import { GroupInvitations } from "../components/GroupInvitations";
import { GroupSwitcher } from "../components/GroupSwitcher";
import { Leaderboard } from "../components/Leaderboard";
import { MatchListView } from "../components/MatchList";
@@ -25,9 +26,10 @@ export function GroupPage() {
<div className="card">
<h3>Активная группа не выбрана</h3>
<p className="muted small">
Создайте группу или выберите существующую, чтобы вести учёт партий.
Создайте группу, выберите существующую или примите приглашение, чтобы вести учёт партий.
</p>
</div>
<GroupInvitations />
<GroupSwitcher />
<CreateGroupForm />
</div>
@@ -51,6 +53,8 @@ export function GroupPage() {
</p>
</div>
<GroupInvitations />
<div className="card">
<h3>Топ в группе</h3>
{stats && <Leaderboard entries={stats.leaderboard} provisional={stats.provisional} />}
+10 -6
View File
@@ -6,7 +6,7 @@ import { Spinner } from "../components/Spinner";
import { MAX_GROUP_SIZE } from "../domain/constants";
import { useToast } from "../context/ToastContext";
import { useMe } from "../hooks/auth";
import { useAddMember, useGroup, useGroupMembers, useRemoveMember, useSetGroupExpansions } from "../hooks/groups";
import { useGroup, useGroupMembers, useInviteMember, useRemoveMember, useSetGroupExpansions } from "../hooks/groups";
import { useExpansions } from "../hooks/reference";
export function GroupSettingsPage() {
@@ -16,7 +16,7 @@ export function GroupSettingsPage() {
const { data: expansions } = useExpansions();
const { data: members } = useGroupMembers(groupId);
const setExpansions = useSetGroupExpansions(groupId ?? 0);
const addMember = useAddMember(groupId ?? 0);
const inviteMember = useInviteMember(groupId ?? 0);
const removeMember = useRemoveMember(groupId ?? 0);
const toast = useToast();
@@ -51,9 +51,9 @@ export function GroupSettingsPage() {
const nick = newNick.trim();
if (!nick) return;
try {
await addMember.mutateAsync(nick);
await inviteMember.mutateAsync(nick);
setNewNick("");
toast.show("Игрок добавлен");
toast.show("Приглашение отправлено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
@@ -102,8 +102,12 @@ export function GroupSettingsPage() {
)}
{isOwner && (members ?? []).length < MAX_GROUP_SIZE && (
<div className="row" style={{ marginBottom: 10 }}>
<input placeholder="Никнейм игрока" value={newNick} onChange={(e) => setNewNick(e.target.value)} />
<button className="btn btn-primary" onClick={add}>
<input
placeholder="Ник игрока — отправить приглашение"
value={newNick}
onChange={(e) => setNewNick(e.target.value)}
/>
<button className="btn btn-primary" onClick={add} disabled={inviteMember.isPending}>
<UserPlus size={18} />
</button>
</div>