Партии: фото в общем комментарии (до 10), загрузка при завершении
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||||
@@ -310,6 +310,32 @@ class MatchParticipant(SQLModel, table=True):
|
|||||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
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):
|
class AuditLog(SQLModel, table=True):
|
||||||
|
|||||||
@@ -1,20 +1,42 @@
|
|||||||
"""Роутер партий: рандом фракции, старт, завершение, детали, правка, удаление."""
|
"""Роутер партий: рандом фракции, старт, завершение, детали, правка, удаление."""
|
||||||
from __future__ import annotations
|
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 sqlmodel import Session
|
||||||
|
|
||||||
from app.auth.deps import get_current_user
|
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.core.timeutil import iso_utc
|
||||||
from app.db.session import get_session
|
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.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
|
from app.services.match_service import FinishInput, ParticipantInput, RosterInput
|
||||||
|
|
||||||
router = APIRouter(prefix="/matches", tags=["matches"])
|
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:
|
def _ensure_has_any_group(session: Session, user: User) -> None:
|
||||||
if not group_service.list_user_groups(session, user.id): # type: ignore[arg-type]
|
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,
|
created_by=match.created_by,
|
||||||
can_modify=can_modify,
|
can_modify=can_modify,
|
||||||
participants=parts,
|
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))
|
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)
|
@router.delete("/{match_id}", response_model=s.OkResponse)
|
||||||
def delete_match(
|
def delete_match(
|
||||||
match_id: int,
|
match_id: int,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Any, Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
@@ -220,6 +220,15 @@ class MatchParticipantRead(BaseModel):
|
|||||||
comment: str | None = None
|
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):
|
class MatchRead(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
group_id: int
|
group_id: int
|
||||||
@@ -234,7 +243,7 @@ class MatchRead(BaseModel):
|
|||||||
created_by: int
|
created_by: int
|
||||||
can_modify: bool = False # может ли текущий зритель править/завершать партию
|
can_modify: bool = False # может ли текущий зритель править/завершать партию
|
||||||
participants: list[MatchParticipantRead] = []
|
participants: list[MatchParticipantRead] = []
|
||||||
attachments: list[Any] = [] # задел под вложения (всегда пусто в v1)
|
attachments: list[AttachmentRead] = []
|
||||||
|
|
||||||
|
|
||||||
# ─── Статистика ──────────────────────────────────────────────────────────────
|
# ─── Статистика ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -318,5 +318,9 @@ def update_match(
|
|||||||
|
|
||||||
|
|
||||||
def delete_match(session: Session, match: Match) -> None:
|
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()
|
session.commit()
|
||||||
|
attachment_service.delete_match_files(match_id) # type: ignore[arg-type] # файлы с диска
|
||||||
|
|||||||
@@ -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)
|
||||||
Vendored
+155
-1
@@ -508,6 +508,41 @@ export interface paths {
|
|||||||
patch: operations["update_match_api_matches__match_id__patch"];
|
patch: operations["update_match_api_matches__match_id__patch"];
|
||||||
trace?: never;
|
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": {
|
"/api/expansions": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1063,6 +1098,21 @@ export interface components {
|
|||||||
/** Is Active */
|
/** Is Active */
|
||||||
is_active?: boolean | null;
|
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 */
|
||||||
AuditLogItem: {
|
AuditLogItem: {
|
||||||
/** Id */
|
/** Id */
|
||||||
@@ -1108,6 +1158,11 @@ export interface components {
|
|||||||
/** Telegram Bot Username */
|
/** Telegram Bot Username */
|
||||||
telegram_bot_username?: string | null;
|
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 */
|
||||||
Body_upload_achievement_icon_api_admin_achievements__slug__icon_put: {
|
Body_upload_achievement_icon_api_admin_achievements__slug__icon_put: {
|
||||||
/** File */
|
/** File */
|
||||||
@@ -1540,7 +1595,7 @@ export interface components {
|
|||||||
* Attachments
|
* Attachments
|
||||||
* @default []
|
* @default []
|
||||||
*/
|
*/
|
||||||
attachments: unknown[];
|
attachments: components["schemas"]["AttachmentRead"][];
|
||||||
};
|
};
|
||||||
/** MatchUpdate */
|
/** MatchUpdate */
|
||||||
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: {
|
list_expansions_api_expansions_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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 MemberRead = S["MemberRead"];
|
||||||
export type InvitationRead = S["InvitationRead"];
|
export type InvitationRead = S["InvitationRead"];
|
||||||
export type MatchRead = S["MatchRead"];
|
export type MatchRead = S["MatchRead"];
|
||||||
|
export type AttachmentRead = S["AttachmentRead"];
|
||||||
export type MatchList = S["MatchList"];
|
export type MatchList = S["MatchList"];
|
||||||
export type MatchListItem = S["MatchListItem"];
|
export type MatchListItem = S["MatchListItem"];
|
||||||
export type Leaderboard = S["Leaderboard"];
|
export type Leaderboard = S["Leaderboard"];
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { qk } from "../api/queryKeys";
|
||||||
import type { FactionRead, MatchCreate, MatchFinish, MatchRead } from "../domain/types";
|
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) {
|
export function useMatch(matchId: number | null) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -73,3 +79,47 @@ export function useDeleteMatch() {
|
|||||||
onSuccess: () => qc.invalidateQueries(),
|
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 { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
|
import { MatchMedia } from "../components/MatchMedia";
|
||||||
import { Spinner } from "../components/Spinner";
|
import { Spinner } from "../components/Spinner";
|
||||||
import { formatDuration, formatTime } from "../domain/format";
|
import { formatDuration, formatTime } from "../domain/format";
|
||||||
import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
|
import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
import { useDeleteMatch, useFinishMatch, useMatch } from "../hooks/matches";
|
import {
|
||||||
|
useDeleteMatch,
|
||||||
|
useDeleteMatchAttachment,
|
||||||
|
useFinishMatch,
|
||||||
|
useMatch,
|
||||||
|
useUploadMatchAttachment,
|
||||||
|
} from "../hooks/matches";
|
||||||
|
|
||||||
interface FinishRow {
|
interface FinishRow {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
@@ -22,6 +29,8 @@ export function MatchDetailPage() {
|
|||||||
const { data: match, isLoading } = useMatch(id);
|
const { data: match, isLoading } = useMatch(id);
|
||||||
const finish = useFinishMatch();
|
const finish = useFinishMatch();
|
||||||
const del = useDeleteMatch();
|
const del = useDeleteMatch();
|
||||||
|
const uploadAtt = useUploadMatchAttachment(id ?? 0);
|
||||||
|
const deleteAtt = useDeleteMatchAttachment(id ?? 0);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -126,6 +135,14 @@ export function MatchDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Медиа (read-only): для завершённых и для зрителей без прав на правку */}
|
||||||
|
{!(inProgress && canModify) && match.attachments.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<h3>Медиа</h3>
|
||||||
|
<MatchMedia attachments={match.attachments} editable={false} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Незавершённая партия — состав + форма завершения */}
|
{/* Незавершённая партия — состав + форма завершения */}
|
||||||
{inProgress && (
|
{inProgress && (
|
||||||
<>
|
<>
|
||||||
@@ -187,6 +204,17 @@ export function MatchDetailPage() {
|
|||||||
<textarea rows={2} value={overall} onChange={(e) => setOverall(e.target.value)} />
|
<textarea rows={2} value={overall} onChange={(e) => setOverall(e.target.value)} />
|
||||||
</div>
|
</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>}
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -135,6 +135,37 @@
|
|||||||
.admin-tabs::-webkit-scrollbar { display: none; } /* WebKit */
|
.admin-tabs::-webkit-scrollbar { display: none; } /* WebKit */
|
||||||
.admin-tabs > * { flex: 0 0 auto; white-space: nowrap; }
|
.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 {
|
.participant-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
Reference in New Issue
Block a user