From 8ac1b5acef738a67a87d0dfd3754eb50b5f0de25 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 17 Jun 2026 21:34:39 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B0=D1=80=D1=82=D0=B8=D0=B8:=20=D1=84?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=20=D0=B2=20=D0=BE=D0=B1=D1=89=D0=B5=D0=BC=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8?= =?UTF-8?q?=D0=B8=20(=D0=B4=D0=BE=2010),=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=B7=D0=BA=D0=B0=20=D0=BF=D1=80=D0=B8=20=D0=B7=D0=B0=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=88=D0=B5=D0=BD=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../versions/0007_match_attachments.py | 56 +++++++ backend/app/models.py | 26 +++ backend/app/routers/matches.py | 95 ++++++++++- backend/app/schemas/api.py | 13 +- backend/app/services/attachment_service.py | 91 ++++++++++ backend/app/services/match_service.py | 6 +- backend/tests/test_attachments.py | 101 ++++++++++++ frontend/src/api/schema.d.ts | 156 +++++++++++++++++- frontend/src/components/MatchMedia.tsx | 106 ++++++++++++ frontend/src/domain/types.ts | 1 + frontend/src/hooks/matches.ts | 52 +++++- frontend/src/lib/image.ts | 37 +++++ frontend/src/pages/MatchDetailPage.tsx | 30 +++- frontend/src/styles/layout.css | 31 ++++ 14 files changed, 790 insertions(+), 11 deletions(-) create mode 100644 backend/alembic/versions/0007_match_attachments.py create mode 100644 backend/app/services/attachment_service.py create mode 100644 backend/tests/test_attachments.py create mode 100644 frontend/src/components/MatchMedia.tsx create mode 100644 frontend/src/lib/image.ts diff --git a/backend/alembic/versions/0007_match_attachments.py b/backend/alembic/versions/0007_match_attachments.py new file mode 100644 index 0000000..c41a52c --- /dev/null +++ b/backend/alembic/versions/0007_match_attachments.py @@ -0,0 +1,56 @@ +"""Медиа партии: таблица match_attachments (фото в общем комментарии). + +Идемпотентна (как 0006): на свежей БД таблицу создаёт 0001 (create_all); на существующей — +create_table здесь. + +Revision ID: 0007_match_attachments +Revises: 0006_group_invitations +Create Date: 2026-06-17 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy import inspect + +from alembic import op + +revision: str = "0007_match_attachments" +down_revision: Union[str, None] = "0006_group_invitations" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + if "match_attachments" in insp.get_table_names(): + return + op.create_table( + "match_attachments", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "match_id", + sa.Integer(), + sa.ForeignKey("matches.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "uploaded_by", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("kind", sa.String(16), nullable=False, server_default="photo"), + sa.Column("storage_path", sa.String(255), nullable=False), + sa.Column("mime_type", sa.String(64), nullable=False), + sa.Column("size_bytes", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + ) + op.create_index("ix_match_attachments_match_id", "match_attachments", ["match_id"]) + + +def downgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + if "match_attachments" in insp.get_table_names(): + op.drop_table("match_attachments") diff --git a/backend/app/models.py b/backend/app/models.py index d57166e..7d2386b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -310,6 +310,32 @@ class MatchParticipant(SQLModel, table=True): created_at: datetime = Field(default_factory=_utcnow, nullable=False) +class MatchAttachment(SQLModel, table=True): + """Медиа партии (фото в общем комментарии). Файл — на томе uploads, в БД метаданные. + + kind пока всегда 'photo' (задел под видео). storage_path — относительный путь под + settings.upload_dir.""" + + __tablename__ = "match_attachments" + + id: int | None = Field(default=None, primary_key=True) + match_id: int = Field( + sa_column=Column( + Integer, ForeignKey("matches.id", ondelete="CASCADE"), nullable=False, index=True + ) + ) + uploaded_by: int | None = Field( + sa_column=Column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + ) + kind: str = Field(default="photo", sa_column=Column(String(16), nullable=False, server_default="photo")) + storage_path: str = Field(sa_column=Column(String(255), nullable=False)) + mime_type: str = Field(sa_column=Column(String(64), nullable=False)) + size_bytes: int = Field(sa_column=Column(Integer, nullable=False)) + created_at: datetime = Field(default_factory=_utcnow, nullable=False) + + # ─── Журнал аудита ─────────────────────────────────────────────────────────── class AuditLog(SQLModel, table=True): diff --git a/backend/app/routers/matches.py b/backend/app/routers/matches.py index 8b38407..ce79e28 100644 --- a/backend/app/routers/matches.py +++ b/backend/app/routers/matches.py @@ -1,20 +1,42 @@ """Роутер партий: рандом фракции, старт, завершение, детали, правка, удаление.""" from __future__ import annotations -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, File, Request, UploadFile +from fastapi.responses import FileResponse from sqlmodel import Session from app.auth.deps import get_current_user -from app.core.errors import NoGroupError +from app.core.errors import ConflictError, NoGroupError, NotFoundError, ValidationError from app.core.timeutil import iso_utc from app.db.session import get_session -from app.models import Match, User +from app.models import Match, MatchAttachment, User from app.schemas import api as s -from app.services import audit_service, group_service, match_service +from app.services import ( + attachment_service, + audit_service, + group_service, + match_service, + user_service, +) from app.services.match_service import FinishInput, ParticipantInput, RosterInput router = APIRouter(prefix="/matches", tags=["matches"]) +_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024 # 10 МБ + + +def attachment_read(att: MatchAttachment, base: str) -> s.AttachmentRead: + """AttachmentRead с URL под нужным префиксом (base = '/api/matches/{id}' или + '/api/admin/matches/{id}'); cookie игрока/админа доходит до своего пути.""" + return s.AttachmentRead( + id=att.id, # type: ignore[arg-type] + kind=att.kind, + url=f"{base}/attachments/{att.id}", + mime_type=att.mime_type, + size_bytes=att.size_bytes, + created_at=iso_utc(att.created_at), + ) + def _ensure_has_any_group(session: Session, user: User) -> None: if not group_service.list_user_groups(session, user.id): # type: ignore[arg-type] @@ -48,7 +70,10 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False created_by=match.created_by, can_modify=can_modify, participants=parts, - attachments=[], + attachments=[ + attachment_read(a, f"/api/matches/{match.id}") + for a in attachment_service.list_for_match(session, match.id) # type: ignore[arg-type] + ], ) @@ -189,6 +214,66 @@ def update_match( return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user)) +# ─── Медиа партии (фото) ────────────────────────────────────────────────────── + +def _assert_can_attach(session: Session, match: Match, user: User) -> None: + match_service.assert_can_modify(session, match, user) + if match.status != "in_progress": + raise ConflictError("Медиа можно прикреплять только до завершения партии.") + + +@router.post("/{match_id}/attachments", response_model=s.AttachmentRead) +def add_attachment( + match_id: int, + file: UploadFile = File(...), + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.AttachmentRead: + match = match_service.get_match(session, match_id) + _assert_can_attach(session, match, user) + content = file.file.read(_ATTACHMENT_MAX_BYTES + 1) + if len(content) > _ATTACHMENT_MAX_BYTES: + raise ValidationError("Файл слишком большой (макс. 10 МБ).") + ext = user_service.sniff_image_ext(content) + if ext is None: + raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.") + att = attachment_service.add_photo( + session, match, user, content, ext, user_service.avatar_media_type(ext) + ) + return attachment_read(att, f"/api/matches/{match_id}") + + +@router.delete("/{match_id}/attachments/{attachment_id}", response_model=s.OkResponse) +def delete_attachment( + match_id: int, + attachment_id: int, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.OkResponse: + match = match_service.get_match(session, match_id) + _assert_can_attach(session, match, user) + attachment_service.delete(session, match, attachment_id) + return s.OkResponse() + + +@router.get("/{match_id}/attachments/{attachment_id}") +def get_attachment( + match_id: int, + attachment_id: int, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> FileResponse: + match = match_service.get_match(session, match_id) + group_service.assert_member(session, match.group_id, user.id) # type: ignore[arg-type] + att = attachment_service.get_for_match(session, match_id, attachment_id) + path = attachment_service.file_path(att) + if not path.exists(): + raise NotFoundError("Файл не найден.") + return FileResponse( + path, media_type=att.mime_type, headers={"Cache-Control": "private, max-age=3600"} + ) + + @router.delete("/{match_id}", response_model=s.OkResponse) def delete_match( match_id: int, diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 859069d..cc6d9c0 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import date -from typing import Any, Literal +from typing import Literal from pydantic import BaseModel, ConfigDict, Field @@ -220,6 +220,15 @@ class MatchParticipantRead(BaseModel): comment: str | None = None +class AttachmentRead(BaseModel): + id: int + kind: str # 'photo' (задел под видео) + url: str + mime_type: str + size_bytes: int + created_at: str + + class MatchRead(BaseModel): id: int group_id: int @@ -234,7 +243,7 @@ class MatchRead(BaseModel): created_by: int can_modify: bool = False # может ли текущий зритель править/завершать партию participants: list[MatchParticipantRead] = [] - attachments: list[Any] = [] # задел под вложения (всегда пусто в v1) + attachments: list[AttachmentRead] = [] # ─── Статистика ────────────────────────────────────────────────────────────── diff --git a/backend/app/services/attachment_service.py b/backend/app/services/attachment_service.py new file mode 100644 index 0000000..5491e33 --- /dev/null +++ b/backend/app/services/attachment_service.py @@ -0,0 +1,91 @@ +"""Вложения партии (фото). Файлы — на томе settings.upload_dir, в БД метаданные. + +Общий сервис для игрового и админского путей (роутеры лишь строят URL с нужным префиксом). +""" +from __future__ import annotations + +import shutil +from pathlib import Path + +from sqlmodel import Session, select + +from app.core.config import settings +from app.core.errors import ConflictError, NotFoundError +from app.models import Match, MatchAttachment, User + +MAX_ATTACHMENTS = 10 +_SUBDIR = "matches" + + +def list_for_match(session: Session, match_id: int) -> list[MatchAttachment]: + return list( + session.exec( + select(MatchAttachment) + .where(MatchAttachment.match_id == match_id) + .order_by(MatchAttachment.created_at, MatchAttachment.id) + ).all() + ) + + +def count(session: Session, match_id: int) -> int: + return len( + session.exec( + select(MatchAttachment.id).where(MatchAttachment.match_id == match_id) + ).all() + ) + + +def file_path(att: MatchAttachment) -> Path: + return Path(settings.upload_dir) / att.storage_path + + +def add_photo( + session: Session, match: Match, user: User, content: bytes, ext: str, mime: str +) -> MatchAttachment: + if count(session, match.id) >= MAX_ATTACHMENTS: # type: ignore[arg-type] + raise ConflictError(f"Можно прикрепить не более {MAX_ATTACHMENTS} файлов.") + att = MatchAttachment( + match_id=match.id, # type: ignore[arg-type] + uploaded_by=user.id, + kind="photo", + storage_path="", + mime_type=mime, + size_bytes=len(content), + ) + session.add(att) + session.flush() # получаем id для имени файла + rel = f"{_SUBDIR}/{match.id}/{att.id}.{ext}" + abs_path = Path(settings.upload_dir) / rel + abs_path.parent.mkdir(parents=True, exist_ok=True) + abs_path.write_bytes(content) + att.storage_path = rel + session.add(att) + session.commit() + session.refresh(att) + return att + + +def get_for_match(session: Session, match_id: int, att_id: int) -> MatchAttachment: + att = session.get(MatchAttachment, att_id) + if att is None or att.match_id != match_id: + raise NotFoundError("Вложение не найдено.") + return att + + +def delete(session: Session, match: Match, att_id: int) -> None: + att = get_for_match(session, match.id, att_id) # type: ignore[arg-type] + path = file_path(att) + if path.exists(): + try: + path.unlink() + except OSError: + pass + session.delete(att) + session.commit() + + +def delete_match_files(match_id: int) -> None: + """Удалить всю папку медиа партии (при удалении партии; строки БД уйдут каскадом).""" + folder = Path(settings.upload_dir) / _SUBDIR / str(match_id) + if folder.is_dir(): + shutil.rmtree(folder, ignore_errors=True) diff --git a/backend/app/services/match_service.py b/backend/app/services/match_service.py index 88369c4..93abd29 100644 --- a/backend/app/services/match_service.py +++ b/backend/app/services/match_service.py @@ -318,5 +318,9 @@ def update_match( def delete_match(session: Session, match: Match) -> None: - session.delete(match) # участники удалятся каскадом (FK ON DELETE CASCADE) + from app.services import attachment_service # избегаем цикла импорта + + match_id = match.id + session.delete(match) # участники и вложения (БД) удалятся каскадом (FK ON DELETE CASCADE) session.commit() + attachment_service.delete_match_files(match_id) # type: ignore[arg-type] # файлы с диска diff --git a/backend/tests/test_attachments.py b/backend/tests/test_attachments.py new file mode 100644 index 0000000..787760b --- /dev/null +++ b/backend/tests/test_attachments.py @@ -0,0 +1,101 @@ +"""Медиа партии (фото): загрузка/просмотр/удаление, валидация, лимит, запрет после финиша.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match + +PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64 + + +def _use_tmp_uploads(monkeypatch, tmp_path) -> None: + from app.core.config import settings + + monkeypatch.setattr(settings, "dev_upload_dir", str(tmp_path)) + + +def _start(client: TestClient, engine) -> tuple[dict, int, int, int]: + me = login(client, "Хост") + exps = [e["id"] for e in client.get("/api/expansions").json()] + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client) + ).json()["id"] + p2 = add_group_member(engine, gid, "Игрок2") + fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] + started = start_match( + client, + gid, + [{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}], + ) + assert started.status_code == 200, started.text + return me, gid, p2, started.json()["id"] + + +def _upload(client: TestClient, mid: int, name: str = "a.png", data: bytes = PNG, mime: str = "image/png"): + return client.post( + f"/api/matches/{mid}/attachments", + files={"file": (name, data, mime)}, + headers=csrf_headers(client), + ) + + +def test_upload_view_delete(client: TestClient, engine, monkeypatch, tmp_path): + _use_tmp_uploads(monkeypatch, tmp_path) + me, gid, p2, mid = _start(client, engine) + + r = _upload(client, mid) + assert r.status_code == 200, r.text + aid = r.json()["id"] + assert r.json()["url"] == f"/api/matches/{mid}/attachments/{aid}" + + detail = client.get(f"/api/matches/{mid}").json() + assert [a["id"] for a in detail["attachments"]] == [aid] + + g = client.get(f"/api/matches/{mid}/attachments/{aid}") + assert g.status_code == 200 and g.content == PNG + assert g.headers["content-type"] == "image/png" + + d = client.delete(f"/api/matches/{mid}/attachments/{aid}", headers=csrf_headers(client)) + assert d.status_code == 200, d.text + assert client.get(f"/api/matches/{mid}").json()["attachments"] == [] + + +def test_reject_non_image_and_limit(client: TestClient, engine, monkeypatch, tmp_path): + _use_tmp_uploads(monkeypatch, tmp_path) + _me, _gid, _p2, mid = _start(client, engine) + + assert _upload(client, mid, "x.txt", b"nope", "text/plain").status_code == 422 + + for i in range(10): + assert _upload(client, mid, f"{i}.png").status_code == 200 + assert _upload(client, mid, "over.png").status_code == 409 + + +def test_no_changes_after_finish(client: TestClient, engine, monkeypatch, tmp_path): + _use_tmp_uploads(monkeypatch, tmp_path) + me, gid, p2, mid = _start(client, engine) + aid = _upload(client, mid).json()["id"] + + fin = finish_match( + client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}], + win_reason="objectives", + ) + assert fin.status_code == 200, fin.text + + # После завершения игрок не может ни добавлять, ни удалять. + assert _upload(client, mid).status_code == 409 + assert client.delete( + f"/api/matches/{mid}/attachments/{aid}", headers=csrf_headers(client) + ).status_code == 409 + # Но просмотр сохраняется. + assert client.get(f"/api/matches/{mid}").json()["attachments"][0]["id"] == aid + + +def test_non_member_cannot_view(client: TestClient, engine, monkeypatch, tmp_path): + _use_tmp_uploads(monkeypatch, tmp_path) + _me, _gid, _p2, mid = _start(client, engine) + aid = _upload(client, mid).json()["id"] + + login(client, "Чужак") # не состоит в группе + g = client.get(f"/api/matches/{mid}/attachments/{aid}") + assert g.status_code in (401, 403) diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 1e91103..7f9bd2c 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -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; diff --git a/frontend/src/components/MatchMedia.tsx b/frontend/src/components/MatchMedia.tsx new file mode 100644 index 0000000..89e0266 --- /dev/null +++ b/frontend/src/components/MatchMedia.tsx @@ -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; + onDelete?: (id: number) => Promise; + pending?: boolean; + max?: number; +}) { + const toast = useToast(); + const fileRef = useRef(null); + const full = attachments.length >= max; + + const pick = async (e: React.ChangeEvent) => { + 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 ( +
+ {attachments.length > 0 && ( +
+ {attachments.map((a) => ( +
+ + фото партии + + {editable && ( + + )} +
+ ))} +
+ )} + {editable && ( + <> + + + + )} +
+ ); +} diff --git a/frontend/src/domain/types.ts b/frontend/src/domain/types.ts index 1782d7f..baf132b 100644 --- a/frontend/src/domain/types.ts +++ b/frontend/src/domain/types.ts @@ -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"]; diff --git a/frontend/src/hooks/matches.ts b/frontend/src/hooks/matches.ts index ff8d836..75f9949 100644 --- a/frontend/src/hooks/matches.ts +++ b/frontend/src/hooks/matches.ts @@ -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) }), + }); +} diff --git a/frontend/src/lib/image.ts b/frontend/src/lib/image.ts new file mode 100644 index 0000000..eb23e2c --- /dev/null +++ b/frontend/src/lib/image.ts @@ -0,0 +1,37 @@ +/** + * Уменьшение изображения на клиенте перед загрузкой (canvas → JPEG). + * Ограничивает большую сторону до `max` px, чтобы не грузить тяжёлые файлы. + * При любой ошибке возвращает исходный файл — сервер всё равно проверит тип/размер. + */ +export async function resizeImage(file: File, max = 1600): Promise { + try { + const dataUrl = await new Promise((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((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((res) => canvas.toBlob(res, "image/jpeg", 0.85)); + return blob ?? file; + } catch { + return file; + } +} diff --git a/frontend/src/pages/MatchDetailPage.tsx b/frontend/src/pages/MatchDetailPage.tsx index e7b37b2..dda2d83 100644 --- a/frontend/src/pages/MatchDetailPage.tsx +++ b/frontend/src/pages/MatchDetailPage.tsx @@ -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() { )} + {/* Медиа (read-only): для завершённых и для зрителей без прав на правку */} + {!(inProgress && canModify) && match.attachments.length > 0 && ( +
+

Медиа

+ +
+ )} + {/* Незавершённая партия — состав + форма завершения */} {inProgress && ( <> @@ -187,6 +204,17 @@ export function MatchDetailPage() {