Админ: управление медиа партии (добавление/удаление в любой момент)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 21:38:06 +03:00
co-authored by Claude Opus 4.8
parent 8ac1b5acef
commit 043595beff
5 changed files with 362 additions and 4 deletions
+171
View File
@@ -834,6 +834,42 @@ export interface paths {
patch: operations["rename_faction_api_admin_factions__faction_id__patch"];
trace?: never;
};
"/api/admin/matches/{match_id}/attachments": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Admin List Attachments */
get: operations["admin_list_attachments_api_admin_matches__match_id__attachments_get"];
put?: never;
/** Admin Add Attachment */
post: operations["admin_add_attachment_api_admin_matches__match_id__attachments_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/matches/{match_id}/attachments/{attachment_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Admin Get Attachment */
get: operations["admin_get_attachment_api_admin_matches__match_id__attachments__attachment_id__get"];
put?: never;
post?: never;
/** Admin Delete Attachment */
delete: operations["admin_delete_attachment_api_admin_matches__match_id__attachments__attachment_id__delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/achievements": {
parameters: {
query?: never;
@@ -1163,6 +1199,11 @@ export interface components {
/** File */
file: string;
};
/** Body_admin_add_attachment_api_admin_matches__match_id__attachments_post */
Body_admin_add_attachment_api_admin_matches__match_id__attachments_post: {
/** File */
file: string;
};
/** Body_upload_achievement_icon_api_admin_achievements__slug__icon_put */
Body_upload_achievement_icon_api_admin_achievements__slug__icon_put: {
/** File */
@@ -3523,6 +3564,136 @@ export interface operations {
};
};
};
admin_list_attachments_api_admin_matches__match_id__attachments_get: {
parameters: {
query?: never;
header?: never;
path: {
match_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AttachmentRead"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
admin_add_attachment_api_admin_matches__match_id__attachments_post: {
parameters: {
query?: never;
header?: never;
path: {
match_id: number;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_admin_add_attachment_api_admin_matches__match_id__attachments_post"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AttachmentRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
admin_get_attachment_api_admin_matches__match_id__attachments__attachment_id__get: {
parameters: {
query?: never;
header?: never;
path: {
match_id: number;
attachment_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
admin_delete_attachment_api_admin_matches__match_id__attachments__attachment_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
match_id: number;
attachment_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"];
};
};
};
};
list_achievements_api_admin_achievements_get: {
parameters: {
query?: never;
+59
View File
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, api, unwrap } from "../api/client";
import { qk } from "../api/queryKeys";
import type { AdminMe, MatchUpdate } from "../domain/types";
import { resizeImage } from "../lib/image";
function readCsrfToken(): string | null {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
@@ -87,6 +88,64 @@ export function useAdminMatch(matchId: number | null) {
});
}
// ─── Медиа партии (админ правит в любой момент) ──────────────────────────────
export function useAdminMatchAttachments(matchId: number | null) {
return useQuery({
queryKey: ["adminMatchAttachments", matchId],
enabled: matchId != null,
queryFn: async () =>
unwrap(
await api.GET("/api/admin/matches/{match_id}/attachments", {
params: { path: { match_id: matchId as number } },
}),
),
});
}
export function useAdminUploadAttachment(matchId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (file: File) => {
const blob = await resizeImage(file);
const form = new FormData();
form.append("file", blob, "photo.jpg");
const csrf = readCsrfToken();
const base = import.meta.env.VITE_API_BASE_URL || "";
const r = await fetch(`${base}/api/admin/matches/${matchId}/attachments`, {
method: "POST",
body: form,
credentials: "include",
headers: csrf ? { "X-CSRF-Token": csrf } : {},
});
if (!r.ok) {
let env: { code?: string; message?: string } | undefined;
try {
env = ((await r.json()) as { error?: typeof env }).error;
} catch {
/* тело без JSON */
}
throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status);
}
return await r.json();
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["adminMatchAttachments", matchId] }),
});
}
export function useAdminDeleteAttachment(matchId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (attachmentId: number) =>
unwrap(
await api.DELETE("/api/admin/matches/{match_id}/attachments/{attachment_id}", {
params: { path: { match_id: matchId, attachment_id: attachmentId } },
}),
),
onSuccess: () => qc.invalidateQueries({ queryKey: ["adminMatchAttachments", matchId] }),
});
}
export function useAdminUpdateMatch() {
const qc = useQueryClient();
return useMutation({
+23 -1
View File
@@ -1,10 +1,18 @@
import { useEffect, useState } from "react";
import { ApiError } from "../../api/client";
import { MatchMedia } from "../../components/MatchMedia";
import { Spinner } from "../../components/Spinner";
import { WIN_REASONS, type WinReason } from "../../domain/winReasons";
import { useToast } from "../../context/ToastContext";
import { useAdminFactions, useAdminMatch, useAdminUpdateMatch } from "../../hooks/admin";
import {
useAdminDeleteAttachment,
useAdminFactions,
useAdminMatch,
useAdminMatchAttachments,
useAdminUpdateMatch,
useAdminUploadAttachment,
} from "../../hooks/admin";
interface Row {
user_id: number;
@@ -18,7 +26,10 @@ interface Row {
export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: () => void }) {
const { data: match, isLoading } = useAdminMatch(matchId);
const { data: factions } = useAdminFactions();
const { data: attachments } = useAdminMatchAttachments(matchId);
const update = useAdminUpdateMatch();
const uploadAtt = useAdminUploadAttachment(matchId);
const deleteAtt = useAdminDeleteAttachment(matchId);
const toast = useToast();
const [rows, setRows] = useState<Row[]>([]);
@@ -150,6 +161,17 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
<textarea rows={2} value={overall} onChange={(e) => setOverall(e.target.value)} />
</div>
<div className="field">
<label className="label">Медиа (до 10)</label>
<MatchMedia
attachments={attachments ?? []}
editable
onUpload={(f) => uploadAtt.mutateAsync(f)}
onDelete={(aid) => deleteAtt.mutateAsync(aid)}
pending={uploadAtt.isPending || deleteAtt.isPending}
/>
</div>
{error && <p className="error-text">{error}</p>}
<button className="btn btn-primary" style={{ width: "100%" }} onClick={save} disabled={update.isPending}>