Партии: фото в общем комментарии (до 10), загрузка при завершении
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Vendored
+155
-1
@@ -508,6 +508,41 @@ export interface paths {
|
||||
patch: operations["update_match_api_matches__match_id__patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/matches/{match_id}/attachments": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Add Attachment */
|
||||
post: operations["add_attachment_api_matches__match_id__attachments_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/matches/{match_id}/attachments/{attachment_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get Attachment */
|
||||
get: operations["get_attachment_api_matches__match_id__attachments__attachment_id__get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** Delete Attachment */
|
||||
delete: operations["delete_attachment_api_matches__match_id__attachments__attachment_id__delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/expansions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1063,6 +1098,21 @@ export interface components {
|
||||
/** Is Active */
|
||||
is_active?: boolean | null;
|
||||
};
|
||||
/** AttachmentRead */
|
||||
AttachmentRead: {
|
||||
/** Id */
|
||||
id: number;
|
||||
/** Kind */
|
||||
kind: string;
|
||||
/** Url */
|
||||
url: string;
|
||||
/** Mime Type */
|
||||
mime_type: string;
|
||||
/** Size Bytes */
|
||||
size_bytes: number;
|
||||
/** Created At */
|
||||
created_at: string;
|
||||
};
|
||||
/** AuditLogItem */
|
||||
AuditLogItem: {
|
||||
/** Id */
|
||||
@@ -1108,6 +1158,11 @@ export interface components {
|
||||
/** Telegram Bot Username */
|
||||
telegram_bot_username?: string | null;
|
||||
};
|
||||
/** Body_add_attachment_api_matches__match_id__attachments_post */
|
||||
Body_add_attachment_api_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 */
|
||||
@@ -1540,7 +1595,7 @@ export interface components {
|
||||
* Attachments
|
||||
* @default []
|
||||
*/
|
||||
attachments: unknown[];
|
||||
attachments: components["schemas"]["AttachmentRead"][];
|
||||
};
|
||||
/** MatchUpdate */
|
||||
MatchUpdate: {
|
||||
@@ -2850,6 +2905,105 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
add_attachment_api_matches__match_id__attachments_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
match_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"multipart/form-data": components["schemas"]["Body_add_attachment_api_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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_attachment_api_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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
delete_attachment_api_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_expansions_api_expansions_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
import { ApiError } from "../api/client";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import type { AttachmentRead } from "../domain/types";
|
||||
|
||||
/**
|
||||
* Сетка фото партии. Переиспользуется на странице партии (игрок) и в админ-правке —
|
||||
* URL/хуки приходят снаружи (cookie игрока и админа доходят до своих путей).
|
||||
*/
|
||||
export function MatchMedia({
|
||||
attachments,
|
||||
editable = false,
|
||||
onUpload,
|
||||
onDelete,
|
||||
pending = false,
|
||||
max = 10,
|
||||
}: {
|
||||
attachments: AttachmentRead[];
|
||||
editable?: boolean;
|
||||
onUpload?: (file: File) => Promise<unknown>;
|
||||
onDelete?: (id: number) => Promise<unknown>;
|
||||
pending?: boolean;
|
||||
max?: number;
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const full = attachments.length >= max;
|
||||
|
||||
const pick = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
e.target.value = "";
|
||||
if (!onUpload) return;
|
||||
let slots = max - attachments.length;
|
||||
for (const f of files) {
|
||||
if (slots <= 0) {
|
||||
toast.show(`Максимум ${max} фото`);
|
||||
break;
|
||||
}
|
||||
try {
|
||||
await onUpload(f);
|
||||
slots--;
|
||||
} catch (err) {
|
||||
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: number) => {
|
||||
if (!onDelete) return;
|
||||
try {
|
||||
await onDelete(id);
|
||||
} catch (e) {
|
||||
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
|
||||
}
|
||||
};
|
||||
|
||||
if (!editable && attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{attachments.length > 0 && (
|
||||
<div className="media-grid">
|
||||
{attachments.map((a) => (
|
||||
<div key={a.id} className="media-item">
|
||||
<a href={a.url} target="_blank" rel="noreferrer">
|
||||
<img src={a.url} alt="фото партии" />
|
||||
</a>
|
||||
{editable && (
|
||||
<button
|
||||
className="media-del"
|
||||
onClick={() => remove(a.id)}
|
||||
disabled={pending}
|
||||
aria-label="Удалить фото"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{editable && (
|
||||
<>
|
||||
<button
|
||||
className="btn small"
|
||||
style={{ marginTop: 8 }}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={pending || full}
|
||||
>
|
||||
{full ? `Максимум ${max} фото` : "Добавить фото"}
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={pick}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export type FactionRead = S["FactionRead"];
|
||||
export type MemberRead = S["MemberRead"];
|
||||
export type InvitationRead = S["InvitationRead"];
|
||||
export type MatchRead = S["MatchRead"];
|
||||
export type AttachmentRead = S["AttachmentRead"];
|
||||
export type MatchList = S["MatchList"];
|
||||
export type MatchListItem = S["MatchListItem"];
|
||||
export type Leaderboard = S["Leaderboard"];
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api, unwrap } from "../api/client";
|
||||
import { ApiError, api, unwrap } from "../api/client";
|
||||
import { qk } from "../api/queryKeys";
|
||||
import type { FactionRead, MatchCreate, MatchFinish, MatchRead } from "../domain/types";
|
||||
import { resizeImage } from "../lib/image";
|
||||
|
||||
function readCsrfToken(): string | null {
|
||||
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
|
||||
export function useMatch(matchId: number | null) {
|
||||
return useQuery({
|
||||
@@ -73,3 +79,47 @@ export function useDeleteMatch() {
|
||||
onSuccess: () => qc.invalidateQueries(),
|
||||
});
|
||||
}
|
||||
|
||||
// Медиа партии: фото грузим multipart'ом отдельным fetch (с ресайзом и CSRF из cookie).
|
||||
export function useUploadMatchAttachment(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/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: qk.match(matchId) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteMatchAttachment(matchId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (attachmentId: number) =>
|
||||
unwrap(
|
||||
await api.DELETE("/api/matches/{match_id}/attachments/{attachment_id}", {
|
||||
params: { path: { match_id: matchId, attachment_id: attachmentId } },
|
||||
}),
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.match(matchId) }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Уменьшение изображения на клиенте перед загрузкой (canvas → JPEG).
|
||||
* Ограничивает большую сторону до `max` px, чтобы не грузить тяжёлые файлы.
|
||||
* При любой ошибке возвращает исходный файл — сервер всё равно проверит тип/размер.
|
||||
*/
|
||||
export async function resizeImage(file: File, max = 1600): Promise<Blob> {
|
||||
try {
|
||||
const dataUrl = await new Promise<string>((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<HTMLImageElement>((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<Blob | null>((res) => canvas.toBlob(res, "image/jpeg", 0.85));
|
||||
return blob ?? file;
|
||||
} catch {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,18 @@ import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { ApiError } from "../api/client";
|
||||
import { MatchMedia } from "../components/MatchMedia";
|
||||
import { Spinner } from "../components/Spinner";
|
||||
import { formatDuration, formatTime } from "../domain/format";
|
||||
import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { useDeleteMatch, useFinishMatch, useMatch } from "../hooks/matches";
|
||||
import {
|
||||
useDeleteMatch,
|
||||
useDeleteMatchAttachment,
|
||||
useFinishMatch,
|
||||
useMatch,
|
||||
useUploadMatchAttachment,
|
||||
} from "../hooks/matches";
|
||||
|
||||
interface FinishRow {
|
||||
user_id: number;
|
||||
@@ -22,6 +29,8 @@ export function MatchDetailPage() {
|
||||
const { data: match, isLoading } = useMatch(id);
|
||||
const finish = useFinishMatch();
|
||||
const del = useDeleteMatch();
|
||||
const uploadAtt = useUploadMatchAttachment(id ?? 0);
|
||||
const deleteAtt = useDeleteMatchAttachment(id ?? 0);
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -126,6 +135,14 @@ export function MatchDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Медиа (read-only): для завершённых и для зрителей без прав на правку */}
|
||||
{!(inProgress && canModify) && match.attachments.length > 0 && (
|
||||
<div className="card">
|
||||
<h3>Медиа</h3>
|
||||
<MatchMedia attachments={match.attachments} editable={false} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Незавершённая партия — состав + форма завершения */}
|
||||
{inProgress && (
|
||||
<>
|
||||
@@ -187,6 +204,17 @@ export function MatchDetailPage() {
|
||||
<textarea rows={2} value={overall} onChange={(e) => setOverall(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="label">Фото партии (до 10)</label>
|
||||
<MatchMedia
|
||||
attachments={match.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
|
||||
|
||||
@@ -135,6 +135,37 @@
|
||||
.admin-tabs::-webkit-scrollbar { display: none; } /* WebKit */
|
||||
.admin-tabs > * { flex: 0 0 auto; white-space: nowrap; }
|
||||
|
||||
/* Сетка медиа (фото партии) */
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.media-item { position: relative; }
|
||||
.media-item img {
|
||||
width: 100%;
|
||||
height: 88px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
display: block;
|
||||
}
|
||||
.media-del {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.participant-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
Reference in New Issue
Block a user