Группы: приглашения вместо авто-добавления (согласиться/отказаться)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
Vendored
+166
-4
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"];
|
||||
|
||||
@@ -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) }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user