ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: filestorage upload и preview вложений
This commit is contained in:
parent
5f2d543cab
commit
c4032e3040
|
|
@ -80,6 +80,16 @@
|
||||||
- единая вертикальная высота для одного класса контролов
|
- единая вертикальная высота для одного класса контролов
|
||||||
- Placeholder и label должны быть читаемы и не прилипать к краям.
|
- Placeholder и label должны быть читаемы и не прилипать к краям.
|
||||||
|
|
||||||
|
## Чекеры
|
||||||
|
- Для бинарных настроек в glass-интерфейсе используется круглый checker в стиле фильтров отображения.
|
||||||
|
- Активное состояние:
|
||||||
|
- круг залит `rgb(var(--nodedc-accent-rgb))`
|
||||||
|
- внутри маленькая точка `rgb(var(--nodedc-on-accent-rgb))`
|
||||||
|
- Неактивное состояние:
|
||||||
|
- круг на мягком `white/10`
|
||||||
|
- без внешнего outline и без синей browser-рамки
|
||||||
|
- Текстовый статус рядом с checker может дублировать состояние, но сам визуальный якорь должен оставаться круглым, а не квадратным checkbox.
|
||||||
|
|
||||||
## Toolbar и верхние панели
|
## Toolbar и верхние панели
|
||||||
- Элементы верхней панели центрируются по одной горизонтальной оси.
|
- Элементы верхней панели центрируются по одной горизонтальной оси.
|
||||||
- Активный layout/tool mode выделяется кругом акцентного цвета, не квадратной плашкой.
|
- Активный layout/tool mode выделяется кругом акцентного цвета, не квадратной плашкой.
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@ x-aws-s3-env: &aws-s3-env
|
||||||
|
|
||||||
x-proxy-env: &proxy-env
|
x-proxy-env: &proxy-env
|
||||||
APP_DOMAIN: ${APP_DOMAIN:-localhost}
|
APP_DOMAIN: ${APP_DOMAIN:-localhost}
|
||||||
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
|
FILE_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824}
|
||||||
|
PROXY_BODY_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824}
|
||||||
CERT_EMAIL: ${CERT_EMAIL}
|
CERT_EMAIL: ${CERT_EMAIL}
|
||||||
CERT_ACME_CA: ${CERT_ACME_CA}
|
CERT_ACME_CA: ${CERT_ACME_CA}
|
||||||
CERT_ACME_DNS: ${CERT_ACME_DNS}
|
CERT_ACME_DNS: ${CERT_ACME_DNS}
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ AWS_SECRET_ACCESS_KEY=secret-key
|
||||||
AWS_S3_ENDPOINT_URL=http://plane-minio:9000
|
AWS_S3_ENDPOINT_URL=http://plane-minio:9000
|
||||||
AWS_S3_BUCKET_NAME=uploads
|
AWS_S3_BUCKET_NAME=uploads
|
||||||
FILE_SIZE_LIMIT=5242880
|
FILE_SIZE_LIMIT=5242880
|
||||||
|
PROXY_BODY_SIZE_LIMIT=1073741824
|
||||||
POSTHOG_API_KEY=
|
POSTHOG_API_KEY=
|
||||||
POSTHOG_HOST=
|
POSTHOG_HOST=
|
||||||
INSTANCE_CHANGELOG_URL=
|
INSTANCE_CHANGELOG_URL=
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@ x-aws-s3-env: &aws-s3-env
|
||||||
|
|
||||||
x-proxy-env: &proxy-env
|
x-proxy-env: &proxy-env
|
||||||
APP_DOMAIN: ${APP_DOMAIN:-localhost}
|
APP_DOMAIN: ${APP_DOMAIN:-localhost}
|
||||||
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
|
FILE_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824}
|
||||||
|
PROXY_BODY_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824}
|
||||||
CERT_EMAIL: ${CERT_EMAIL}
|
CERT_EMAIL: ${CERT_EMAIL}
|
||||||
CERT_ACME_CA: ${CERT_ACME_CA}
|
CERT_ACME_CA: ${CERT_ACME_CA}
|
||||||
CERT_ACME_DNS: ${CERT_ACME_DNS}
|
CERT_ACME_DNS: ${CERT_ACME_DNS}
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ AWS_SECRET_ACCESS_KEY=secret-key
|
||||||
AWS_S3_ENDPOINT_URL=http://plane-minio:9000
|
AWS_S3_ENDPOINT_URL=http://plane-minio:9000
|
||||||
AWS_S3_BUCKET_NAME=uploads
|
AWS_S3_BUCKET_NAME=uploads
|
||||||
FILE_SIZE_LIMIT=5242880
|
FILE_SIZE_LIMIT=5242880
|
||||||
|
PROXY_BODY_SIZE_LIMIT=1073741824
|
||||||
POSTHOG_API_KEY=
|
POSTHOG_API_KEY=
|
||||||
POSTHOG_HOST=
|
POSTHOG_HOST=
|
||||||
INSTANCE_CHANGELOG_URL=
|
INSTANCE_CHANGELOG_URL=
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ from plane.utils.openapi import (
|
||||||
asset_docs,
|
asset_docs,
|
||||||
)
|
)
|
||||||
from plane.utils.exception_logger import log_exception
|
from plane.utils.exception_logger import log_exception
|
||||||
|
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
|
||||||
|
|
||||||
|
|
||||||
class UserAssetEndpoint(BaseAPIView):
|
class UserAssetEndpoint(BaseAPIView):
|
||||||
|
|
@ -512,9 +513,6 @@ class GenericAssetEndpoint(BaseAPIView):
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if the file size is within the limit
|
|
||||||
size_limit = min(size, settings.FILE_SIZE_LIMIT)
|
|
||||||
|
|
||||||
# Check if the file type is allowed
|
# Check if the file type is allowed
|
||||||
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
|
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
|
||||||
return Response(
|
return Response(
|
||||||
|
|
@ -525,6 +523,9 @@ class GenericAssetEndpoint(BaseAPIView):
|
||||||
# Get the workspace
|
# Get the workspace
|
||||||
workspace = Workspace.objects.get(slug=slug)
|
workspace = Workspace.objects.get(slug=slug)
|
||||||
|
|
||||||
|
# Check if the file size is within the limit
|
||||||
|
size_limit = resolve_workspace_upload_size_limit(workspace, size)
|
||||||
|
|
||||||
# asset key
|
# asset key
|
||||||
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,8 @@ from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||||
from .base import BaseAPIView
|
from .base import BaseAPIView
|
||||||
from plane.utils.host import base_host
|
from plane.utils.host import base_host
|
||||||
from plane.utils.issue_relation_mapper import get_actual_relation
|
from plane.utils.issue_relation_mapper import get_actual_relation
|
||||||
|
from plane.utils.attachment_preview import attachment_object_exists, get_attachment_preview_response
|
||||||
|
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
|
||||||
from plane.bgtasks.webhook_task import model_activity
|
from plane.bgtasks.webhook_task import model_activity
|
||||||
from plane.app.permissions import ROLE
|
from plane.app.permissions import ROLE
|
||||||
from plane.utils.openapi import (
|
from plane.utils.openapi import (
|
||||||
|
|
@ -1874,8 +1876,6 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
size_limit = min(size, settings.FILE_SIZE_LIMIT)
|
|
||||||
|
|
||||||
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
|
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Invalid file type.", "status": False},
|
{"error": "Invalid file type.", "status": False},
|
||||||
|
|
@ -1885,6 +1885,8 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
|
||||||
# Get the workspace
|
# Get the workspace
|
||||||
workspace = Workspace.objects.get(slug=slug)
|
workspace = Workspace.objects.get(slug=slug)
|
||||||
|
|
||||||
|
size_limit = resolve_workspace_upload_size_limit(workspace, size)
|
||||||
|
|
||||||
# asset key
|
# asset key
|
||||||
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
||||||
|
|
||||||
|
|
@ -2100,13 +2102,8 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
storage = S3Storage(request=request)
|
disposition = "inline" if request.GET.get("preview") == "true" else "attachment"
|
||||||
presigned_url = storage.generate_presigned_url(
|
return get_attachment_preview_response(request, asset, disposition=disposition)
|
||||||
object_name=asset.asset.name,
|
|
||||||
disposition="attachment",
|
|
||||||
filename=asset.attributes.get("name"),
|
|
||||||
)
|
|
||||||
return HttpResponseRedirect(presigned_url)
|
|
||||||
|
|
||||||
@issue_attachment_docs(
|
@issue_attachment_docs(
|
||||||
operation_id="upload_work_item_attachment",
|
operation_id="upload_work_item_attachment",
|
||||||
|
|
@ -2157,6 +2154,11 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
|
||||||
|
|
||||||
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
|
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
|
||||||
serializer = IssueAttachmentSerializer(issue_attachment)
|
serializer = IssueAttachmentSerializer(issue_attachment)
|
||||||
|
if not attachment_object_exists(issue_attachment):
|
||||||
|
return Response(
|
||||||
|
{"error": "The uploaded attachment object was not found.", "status": False},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
# Send this activity only if the attachment is not uploaded before
|
# Send this activity only if the attachment is not uploaded before
|
||||||
if not issue_attachment.is_uploaded:
|
if not issue_attachment.is_uploaded:
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,7 @@ class DynamicBaseSerializer(BaseSerializer):
|
||||||
issue_attachments = FileAsset.objects.filter(
|
issue_attachments = FileAsset.objects.filter(
|
||||||
issue_id=issue_id,
|
issue_id=issue_id,
|
||||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||||
|
is_uploaded=True,
|
||||||
)
|
)
|
||||||
# Serialize issue_attachments and add them to the response
|
# Serialize issue_attachments and add them to the response
|
||||||
response["issue_attachments"] = IssueAttachmentLiteSerializer(issue_attachments, many=True).data
|
response["issue_attachments"] = IssueAttachmentLiteSerializer(issue_attachments, many=True).data
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,11 @@ class WorkSpaceSerializer(DynamicBaseSerializer):
|
||||||
)
|
)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
def validate_storage_file_size_limit(self, value):
|
||||||
|
if value is not None and int(value) < 1:
|
||||||
|
raise serializers.ValidationError("Storage file size limit must be greater than zero")
|
||||||
|
return value
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Workspace
|
model = Workspace
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ from plane.app.permissions import allow_permission, ROLE
|
||||||
from plane.utils.cache import invalidate_cache_directly
|
from plane.utils.cache import invalidate_cache_directly
|
||||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||||
from plane.throttles.asset import AssetRateThrottle
|
from plane.throttles.asset import AssetRateThrottle
|
||||||
|
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
|
||||||
|
|
||||||
|
|
||||||
class UserAssetsV2Endpoint(BaseAPIView):
|
class UserAssetsV2Endpoint(BaseAPIView):
|
||||||
|
|
@ -342,12 +343,12 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get the size limit
|
|
||||||
size_limit = min(settings.FILE_SIZE_LIMIT, size)
|
|
||||||
|
|
||||||
# Get the workspace
|
# Get the workspace
|
||||||
workspace = Workspace.objects.get(slug=slug)
|
workspace = Workspace.objects.get(slug=slug)
|
||||||
|
|
||||||
|
# Get the size limit
|
||||||
|
size_limit = resolve_workspace_upload_size_limit(workspace, size)
|
||||||
|
|
||||||
# asset key
|
# asset key
|
||||||
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
||||||
|
|
||||||
|
|
@ -541,12 +542,12 @@ class ProjectAssetEndpoint(BaseAPIView):
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get the size limit
|
|
||||||
size_limit = min(settings.FILE_SIZE_LIMIT, size)
|
|
||||||
|
|
||||||
# Get the workspace
|
# Get the workspace
|
||||||
workspace = Workspace.objects.get(slug=slug)
|
workspace = Workspace.objects.get(slug=slug)
|
||||||
|
|
||||||
|
# Get the size limit
|
||||||
|
size_limit = resolve_workspace_upload_size_limit(workspace, size)
|
||||||
|
|
||||||
# asset key
|
# asset key
|
||||||
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import uuid
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import HttpResponseRedirect
|
|
||||||
|
|
||||||
# Third Party imports
|
# Third Party imports
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
@ -26,6 +25,8 @@ from plane.app.permissions import allow_permission, ROLE
|
||||||
from plane.settings.storage import S3Storage
|
from plane.settings.storage import S3Storage
|
||||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||||
from plane.utils.host import base_host
|
from plane.utils.host import base_host
|
||||||
|
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
|
||||||
|
from plane.utils.attachment_preview import attachment_object_exists, get_attachment_preview_response
|
||||||
|
|
||||||
|
|
||||||
class IssueAttachmentEndpoint(BaseAPIView):
|
class IssueAttachmentEndpoint(BaseAPIView):
|
||||||
|
|
@ -86,7 +87,13 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||||
|
|
||||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||||
def get(self, request, slug, project_id, issue_id):
|
def get(self, request, slug, project_id, issue_id):
|
||||||
issue_attachments = FileAsset.objects.filter(issue_id=issue_id, workspace__slug=slug, project_id=project_id)
|
issue_attachments = FileAsset.objects.filter(
|
||||||
|
issue_id=issue_id,
|
||||||
|
workspace__slug=slug,
|
||||||
|
project_id=project_id,
|
||||||
|
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||||
|
is_uploaded=True,
|
||||||
|
)
|
||||||
serializer = IssueAttachmentSerializer(issue_attachments, many=True)
|
serializer = IssueAttachmentSerializer(issue_attachments, many=True)
|
||||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
@ -114,7 +121,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
||||||
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
||||||
|
|
||||||
# Get the size limit
|
# Get the size limit
|
||||||
size_limit = min(size, settings.FILE_SIZE_LIMIT)
|
size_limit = resolve_workspace_upload_size_limit(workspace, size)
|
||||||
|
|
||||||
# Create a File Asset
|
# Create a File Asset
|
||||||
asset = FileAsset.objects.create(
|
asset = FileAsset.objects.create(
|
||||||
|
|
@ -179,13 +186,8 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
storage = S3Storage(request=request)
|
disposition = "inline" if request.GET.get("preview") == "true" else "attachment"
|
||||||
presigned_url = storage.generate_presigned_url(
|
return get_attachment_preview_response(request, asset, disposition=disposition)
|
||||||
object_name=asset.asset.name,
|
|
||||||
disposition="attachment",
|
|
||||||
filename=asset.attributes.get("name"),
|
|
||||||
)
|
|
||||||
return HttpResponseRedirect(presigned_url)
|
|
||||||
|
|
||||||
# Get all the attachments
|
# Get all the attachments
|
||||||
issue_attachments = FileAsset.objects.filter(
|
issue_attachments = FileAsset.objects.filter(
|
||||||
|
|
@ -203,6 +205,11 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
||||||
def patch(self, request, slug, project_id, issue_id, pk):
|
def patch(self, request, slug, project_id, issue_id, pk):
|
||||||
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
|
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
|
||||||
serializer = IssueAttachmentSerializer(issue_attachment)
|
serializer = IssueAttachmentSerializer(issue_attachment)
|
||||||
|
if not attachment_object_exists(issue_attachment):
|
||||||
|
return Response(
|
||||||
|
{"error": "The uploaded attachment object was not found.", "status": False},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
# Send this activity only if the attachment is not uploaded before
|
# Send this activity only if the attachment is not uploaded before
|
||||||
if not issue_attachment.is_uploaded:
|
if not issue_attachment.is_uploaded:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Codex on 2026-04-25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("db", "0125_voice_task_sessions"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workspace",
|
||||||
|
name="storage_file_size_limit_enabled",
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workspace",
|
||||||
|
name="storage_file_size_limit",
|
||||||
|
field=models.PositiveBigIntegerField(default=5242880),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -137,6 +137,8 @@ class Workspace(BaseModel):
|
||||||
organization_size = models.CharField(max_length=20, blank=True, null=True)
|
organization_size = models.CharField(max_length=20, blank=True, null=True)
|
||||||
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
|
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
|
||||||
background_color = models.CharField(max_length=255, default=get_random_color)
|
background_color = models.CharField(max_length=255, default=get_random_color)
|
||||||
|
storage_file_size_limit_enabled = models.BooleanField(default=True)
|
||||||
|
storage_file_size_limit = models.PositiveBigIntegerField(default=5242880)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""Return name of the Workspace"""
|
"""Return name of the Workspace"""
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from plane.utils.upload_limits import get_workspace_file_size_limit, resolve_workspace_upload_size_limit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestWorkspaceUploadLimits:
|
||||||
|
def test_returns_none_when_workspace_limit_is_disabled(self):
|
||||||
|
workspace = SimpleNamespace(storage_file_size_limit_enabled=False, storage_file_size_limit=1024)
|
||||||
|
|
||||||
|
assert get_workspace_file_size_limit(workspace) is None
|
||||||
|
|
||||||
|
def test_caps_requested_size_by_workspace_limit(self):
|
||||||
|
workspace = SimpleNamespace(storage_file_size_limit_enabled=True, storage_file_size_limit=1024)
|
||||||
|
|
||||||
|
assert resolve_workspace_upload_size_limit(workspace, 4096) == 1024
|
||||||
|
|
||||||
|
def test_uses_requested_size_when_workspace_limit_is_disabled(self):
|
||||||
|
workspace = SimpleNamespace(storage_file_size_limit_enabled=False, storage_file_size_limit=1024)
|
||||||
|
|
||||||
|
assert resolve_workspace_upload_size_limit(workspace, 4096) == 4096
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
# See the LICENSE file for details.
|
||||||
|
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
from django.http import HttpResponse, StreamingHttpResponse
|
||||||
|
|
||||||
|
from plane.settings.storage import S3Storage
|
||||||
|
|
||||||
|
|
||||||
|
def attachment_object_exists(asset):
|
||||||
|
storage = S3Storage(request=None)
|
||||||
|
try:
|
||||||
|
storage.s3_client.head_object(
|
||||||
|
Bucket=storage.aws_storage_bucket_name,
|
||||||
|
Key=str(asset.asset.name),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except ClientError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_attachment_preview_response(request, asset, disposition="inline"):
|
||||||
|
storage = S3Storage(request=None)
|
||||||
|
range_header = request.META.get("HTTP_RANGE")
|
||||||
|
request_kwargs = {
|
||||||
|
"Bucket": storage.aws_storage_bucket_name,
|
||||||
|
"Key": str(asset.asset.name),
|
||||||
|
}
|
||||||
|
if range_header:
|
||||||
|
request_kwargs["Range"] = range_header
|
||||||
|
|
||||||
|
try:
|
||||||
|
storage_response = storage.s3_client.get_object(**request_kwargs)
|
||||||
|
except ClientError:
|
||||||
|
return HttpResponse("Attachment object not found.", status=404)
|
||||||
|
|
||||||
|
content_type = (
|
||||||
|
asset.attributes.get("type")
|
||||||
|
or storage_response.get("ContentType")
|
||||||
|
or "application/octet-stream"
|
||||||
|
)
|
||||||
|
filename = quote(asset.attributes.get("name") or "attachment")
|
||||||
|
response = StreamingHttpResponse(
|
||||||
|
storage_response["Body"].iter_chunks(chunk_size=8192),
|
||||||
|
status=206 if storage_response.get("ContentRange") else 200,
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
response["Content-Disposition"] = f"{disposition}; filename*=UTF-8''{filename}"
|
||||||
|
response["Accept-Ranges"] = "bytes"
|
||||||
|
|
||||||
|
if storage_response.get("ContentLength") is not None:
|
||||||
|
response["Content-Length"] = storage_response["ContentLength"]
|
||||||
|
if storage_response.get("ContentRange"):
|
||||||
|
response["Content-Range"] = storage_response["ContentRange"]
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
def get_workspace_file_size_limit(workspace):
|
||||||
|
if not getattr(workspace, "storage_file_size_limit_enabled", True):
|
||||||
|
return None
|
||||||
|
|
||||||
|
limit = getattr(workspace, "storage_file_size_limit", None) or settings.FILE_SIZE_LIMIT
|
||||||
|
return max(1, int(limit))
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_workspace_upload_size_limit(workspace, requested_size):
|
||||||
|
try:
|
||||||
|
requested_size = int(requested_size)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
requested_size = settings.FILE_SIZE_LIMIT
|
||||||
|
|
||||||
|
requested_size = max(1, requested_size)
|
||||||
|
workspace_limit = get_workspace_file_size_limit(workspace)
|
||||||
|
|
||||||
|
if workspace_limit is None:
|
||||||
|
return requested_size
|
||||||
|
|
||||||
|
return min(requested_size, workspace_limit)
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
(plane_proxy) {
|
(plane_proxy) {
|
||||||
request_body {
|
request_body {
|
||||||
max_size {$FILE_SIZE_LIMIT}
|
max_size {$PROXY_BODY_SIZE_LIMIT:1073741824}
|
||||||
}
|
}
|
||||||
|
|
||||||
handle /spaces/* {
|
handle /spaces/* {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
(plane_proxy) {
|
(plane_proxy) {
|
||||||
request_body {
|
request_body {
|
||||||
max_size {$FILE_SIZE_LIMIT}
|
max_size {$PROXY_BODY_SIZE_LIMIT:1073741824}
|
||||||
}
|
}
|
||||||
|
|
||||||
redir /spaces /spaces/ permanent
|
redir /spaces /spaces/ permanent
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { WorkspaceContentWrapper } from "@/plane-web/components/workspace/conten
|
||||||
import { AppRailVisibilityProvider } from "@/plane-web/hooks/app-rail";
|
import { AppRailVisibilityProvider } from "@/plane-web/hooks/app-rail";
|
||||||
import { GlobalModals } from "@/plane-web/components/common/modal/global";
|
import { GlobalModals } from "@/plane-web/components/common/modal/global";
|
||||||
import { WorkspaceAuthWrapper } from "@/layouts/auth-layout/workspace-wrapper";
|
import { WorkspaceAuthWrapper } from "@/layouts/auth-layout/workspace-wrapper";
|
||||||
|
import { WorkspaceSettingsModal } from "@/components/workspace/settings/workspace-settings-modal";
|
||||||
import type { Route } from "./+types/layout";
|
import type { Route } from "./+types/layout";
|
||||||
|
|
||||||
export default function WorkspaceLayout(props: Route.ComponentProps) {
|
export default function WorkspaceLayout(props: Route.ComponentProps) {
|
||||||
|
|
@ -21,6 +22,7 @@ export default function WorkspaceLayout(props: Route.ComponentProps) {
|
||||||
<AppRailVisibilityProvider>
|
<AppRailVisibilityProvider>
|
||||||
<WorkspaceContentWrapper workspaceSlug={workspaceSlug}>
|
<WorkspaceContentWrapper workspaceSlug={workspaceSlug}>
|
||||||
<GlobalModals workspaceSlug={workspaceSlug} />
|
<GlobalModals workspaceSlug={workspaceSlug} />
|
||||||
|
<WorkspaceSettingsModal />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</WorkspaceContentWrapper>
|
</WorkspaceContentWrapper>
|
||||||
</AppRailVisibilityProvider>
|
</AppRailVisibilityProvider>
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,30 @@
|
||||||
import { MAX_FILE_SIZE } from "@plane/constants";
|
import { MAX_FILE_SIZE } from "@plane/constants";
|
||||||
// hooks
|
// hooks
|
||||||
import { useInstance } from "@/hooks/store/use-instance";
|
import { useInstance } from "@/hooks/store/use-instance";
|
||||||
|
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||||
|
|
||||||
type TReturnProps = {
|
type TReturnProps = {
|
||||||
maxFileSize: number;
|
maxFileSize: number;
|
||||||
|
fileSizeLimitEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useFileSize = (): TReturnProps => {
|
export const useFileSize = (): TReturnProps => {
|
||||||
// store hooks
|
// store hooks
|
||||||
const { config } = useInstance();
|
const { config } = useInstance();
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
|
||||||
|
const workspaceLimitEnabled = currentWorkspace?.storage_file_size_limit_enabled ?? true;
|
||||||
|
const workspaceLimit = currentWorkspace?.storage_file_size_limit;
|
||||||
|
const fallbackLimit = config?.file_size_limit ?? MAX_FILE_SIZE;
|
||||||
|
const maxFileSize =
|
||||||
|
workspaceLimitEnabled && workspaceLimit && workspaceLimit > 0
|
||||||
|
? workspaceLimit
|
||||||
|
: workspaceLimitEnabled
|
||||||
|
? fallbackLimit
|
||||||
|
: Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
maxFileSize: config?.file_size_limit ?? MAX_FILE_SIZE,
|
maxFileSize,
|
||||||
|
fileSizeLimitEnabled: workspaceLimitEnabled,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ export const IssueAttachmentsDetail = observer(function IssueAttachmentsDetail(p
|
||||||
// derived values
|
// derived values
|
||||||
const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined;
|
const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined;
|
||||||
const fileName = getFileName(attachment?.attributes.name ?? "");
|
const fileName = getFileName(attachment?.attributes.name ?? "");
|
||||||
const fileExtension = getFileExtension(attachment?.asset_url ?? "");
|
const fileExtension = getFileExtension(attachment?.attributes.name ?? "");
|
||||||
const fileIcon = getFileIcon(fileExtension, 28);
|
const fileIcon = getFileIcon(fileExtension, 28);
|
||||||
const fileURL = getFileURL(attachment?.asset_url ?? "");
|
const fileURL = getFileURL(attachment?.asset_url ?? "");
|
||||||
// hooks
|
// hooks
|
||||||
|
|
|
||||||
|
|
@ -51,21 +51,33 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
|
||||||
attachment: { getAttachmentsByIssueId },
|
attachment: { getAttachmentsByIssueId },
|
||||||
attachmentDeleteModalId,
|
attachmentDeleteModalId,
|
||||||
toggleDeleteAttachmentModal,
|
toggleDeleteAttachmentModal,
|
||||||
|
fetchAttachments,
|
||||||
fetchActivities,
|
fetchActivities,
|
||||||
} = useIssueDetail(issueServiceType);
|
} = useIssueDetail(issueServiceType);
|
||||||
const { operations: attachmentOperations, snapshot: attachmentSnapshot } = attachmentHelpers;
|
const { operations: attachmentOperations, snapshot: attachmentSnapshot } = attachmentHelpers;
|
||||||
const { create: createAttachment } = attachmentOperations;
|
const { create: createAttachment } = attachmentOperations;
|
||||||
const { uploadStatus } = attachmentSnapshot;
|
const { uploadStatus } = attachmentSnapshot;
|
||||||
// file size
|
// file size
|
||||||
const { maxFileSize } = useFileSize();
|
const { fileSizeLimitEnabled, maxFileSize } = useFileSize();
|
||||||
// derived values
|
// derived values
|
||||||
const issueAttachments = getAttachmentsByIssueId(issueId) ?? [];
|
const issueAttachments = getAttachmentsByIssueId(issueId) ?? [];
|
||||||
|
const activeUploads = uploadStatus ?? [];
|
||||||
const hasAttachmentRows = issueAttachments.length > 0 || !!uploadStatus?.length;
|
const hasAttachmentRows = issueAttachments.length > 0 || !!uploadStatus?.length;
|
||||||
|
const uploadProgress =
|
||||||
|
activeUploads.length > 0
|
||||||
|
? Math.round(activeUploads.reduce((progressSum, item) => progressSum + item.progress, 0) / activeUploads.length)
|
||||||
|
: 0;
|
||||||
|
|
||||||
// handlers
|
// handlers
|
||||||
const handleFetchPropertyActivities = useCallback(() => {
|
const handleFetchPropertyActivities = useCallback(() => {
|
||||||
fetchActivities(workspaceSlug, projectId, issueId);
|
fetchActivities(workspaceSlug, projectId, issueId);
|
||||||
}, [fetchActivities, workspaceSlug, projectId, issueId]);
|
}, [fetchActivities, workspaceSlug, projectId, issueId]);
|
||||||
|
const handleRefreshAttachments = useCallback(() => {
|
||||||
|
if (!workspaceSlug || !projectId || !issueId) return;
|
||||||
|
fetchAttachments(workspaceSlug, projectId, issueId).catch((error) => {
|
||||||
|
console.error("Error in refreshing issue attachments after upload:", error);
|
||||||
|
});
|
||||||
|
}, [fetchAttachments, workspaceSlug, projectId, issueId]);
|
||||||
|
|
||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
|
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
|
||||||
|
|
@ -73,38 +85,39 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
|
||||||
if (!workspaceSlug) return;
|
if (!workspaceSlug) return;
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
|
||||||
Promise.allSettled(acceptedFiles.map((file) => createAttachment(file)))
|
Promise.allSettled(acceptedFiles.map((file) => createAttachment(file))).finally(() => {
|
||||||
.then((results) => {
|
handleRefreshAttachments();
|
||||||
const failedUploads = results.filter((result) => result.status === "rejected").length;
|
window.setTimeout(handleRefreshAttachments, 1200);
|
||||||
if (failedUploads > 0)
|
handleFetchPropertyActivities();
|
||||||
setToast({
|
setIsUploading(false);
|
||||||
type: TOAST_TYPE.ERROR,
|
});
|
||||||
title: t("toast.error"),
|
|
||||||
message: t("attachment.error"),
|
|
||||||
});
|
|
||||||
return undefined;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
handleFetchPropertyActivities();
|
|
||||||
setIsUploading(false);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rejectedFiles.length > 0)
|
if (rejectedFiles.length > 0)
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.ERROR,
|
type: TOAST_TYPE.ERROR,
|
||||||
title: t("toast.error"),
|
title: t("toast.error"),
|
||||||
message: t("attachment.file_size_limit", { size: maxFileSize / 1024 / 1024 }),
|
message: fileSizeLimitEnabled
|
||||||
|
? t("attachment.file_size_limit", { size: maxFileSize / 1024 / 1024 })
|
||||||
|
: t("attachment.error"),
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
[createAttachment, maxFileSize, workspaceSlug, handleFetchPropertyActivities, t]
|
[
|
||||||
|
createAttachment,
|
||||||
|
fileSizeLimitEnabled,
|
||||||
|
maxFileSize,
|
||||||
|
workspaceSlug,
|
||||||
|
handleRefreshAttachments,
|
||||||
|
handleFetchPropertyActivities,
|
||||||
|
t,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({
|
const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({
|
||||||
onDrop,
|
onDrop,
|
||||||
maxSize: maxFileSize,
|
maxSize: fileSizeLimitEnabled ? maxFileSize : undefined,
|
||||||
multiple: true,
|
multiple: true,
|
||||||
disabled: isUploading || disabled,
|
disabled: isUploading || disabled,
|
||||||
});
|
});
|
||||||
|
|
@ -139,7 +152,9 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
|
||||||
{isDragActive ? "Отпустите файлы здесь" : "Перетащите файлы сюда или нажмите для выбора"}
|
{isDragActive ? "Отпустите файлы здесь" : "Перетащите файлы сюда или нажмите для выбора"}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 truncate text-12 text-tertiary">
|
<div className="mt-1 truncate text-12 text-tertiary">
|
||||||
Любой формат, несколько файлов за раз, до {maxFileSize / 1024 / 1024} МБ на файл
|
{fileSizeLimitEnabled
|
||||||
|
? `Любой формат, несколько файлов за раз, до ${maxFileSize / 1024 / 1024} МБ на файл`
|
||||||
|
: "Любой формат, несколько файлов за раз, без ограничения размера"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -148,11 +163,22 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeUploads.length > 0 && (
|
||||||
|
<div className="overflow-hidden rounded-full bg-white/6">
|
||||||
|
<div
|
||||||
|
className="h-1 rounded-full bg-[rgb(var(--nodedc-accent-rgb))] transition-[width] duration-200 ease-out"
|
||||||
|
style={{ width: `${uploadProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasAttachmentRows && (
|
{hasAttachmentRows && (
|
||||||
<div className="rounded-[1.35rem] border border-subtle bg-surface-1/60 p-3 shadow-[0_12px_28px_rgba(0,0,0,0.08)]">
|
<div className="rounded-[1.35rem] border border-subtle bg-surface-1/60 p-3 shadow-[0_12px_28px_rgba(0,0,0,0.08)]">
|
||||||
<div className="mb-2 flex items-center justify-between gap-3 px-1">
|
<div className="mb-2 flex items-center justify-between gap-3 px-1">
|
||||||
<div className="text-12 font-semibold uppercase tracking-[0.08em] text-tertiary">Вложения</div>
|
<div className="text-12 font-semibold tracking-[0.08em] text-tertiary uppercase">Вложения</div>
|
||||||
<div className="text-12 text-tertiary">{issueAttachments.length}</div>
|
<div className="text-12 text-tertiary">
|
||||||
|
{activeUploads.length > 0 ? `Загрузка ${uploadProgress}%` : issueAttachments.length}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 overflow-x-auto pb-1">
|
<div className="flex gap-3 overflow-x-auto pb-1">
|
||||||
{uploadStatus?.map((status) => (
|
{uploadStatus?.map((status) => (
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,12 @@ import { EIssueServiceType } from "@plane/types";
|
||||||
import type { TContextMenuItem } from "@plane/ui";
|
import type { TContextMenuItem } from "@plane/ui";
|
||||||
import { ActionDropdown, EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
import { ActionDropdown, EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||||
import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils";
|
import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils";
|
||||||
import { Download, FileText, ImageIcon, Play, X } from "lucide-react";
|
import { Download, ImageIcon, Play, X } from "lucide-react";
|
||||||
// components
|
// components
|
||||||
//
|
//
|
||||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||||
import { getFileIcon } from "@/components/icons";
|
import { getFileIcon } from "@/components/icons";
|
||||||
|
import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attachment-pdf-preview";
|
||||||
// helpers
|
// helpers
|
||||||
// hooks
|
// hooks
|
||||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||||
|
|
@ -38,6 +39,13 @@ const IMAGE_EXTENSIONS = new Set(["apng", "avif", "bmp", "gif", "jpg", "jpeg", "
|
||||||
const VIDEO_EXTENSIONS = new Set(["avi", "m4v", "mov", "mp4", "mpeg", "mpg", "ogv", "webm"]);
|
const VIDEO_EXTENSIONS = new Set(["avi", "m4v", "mov", "mp4", "mpeg", "mpg", "ogv", "webm"]);
|
||||||
const PDF_EXTENSIONS = new Set(["pdf"]);
|
const PDF_EXTENSIONS = new Set(["pdf"]);
|
||||||
|
|
||||||
|
const appendSearchParam = (url: string | undefined, key: string, value: string): string => {
|
||||||
|
if (!url) return "";
|
||||||
|
const [urlWithoutHash, hash] = url.split("#");
|
||||||
|
const separator = urlWithoutHash.includes("?") ? "&" : "?";
|
||||||
|
return `${urlWithoutHash}${separator}${key}=${encodeURIComponent(value)}${hash ? `#${hash}` : ""}`;
|
||||||
|
};
|
||||||
|
|
||||||
const getPreviewType = (extension: string) => {
|
const getPreviewType = (extension: string) => {
|
||||||
const normalizedExtension = extension.toLowerCase();
|
const normalizedExtension = extension.toLowerCase();
|
||||||
if (IMAGE_EXTENSIONS.has(normalizedExtension)) return "image";
|
if (IMAGE_EXTENSIONS.has(normalizedExtension)) return "image";
|
||||||
|
|
@ -64,7 +72,9 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
const previewType = getPreviewType(fileExtension);
|
const previewType = getPreviewType(fileExtension);
|
||||||
const fileIcon = getFileIcon(fileExtension, 32);
|
const fileIcon = getFileIcon(fileExtension, 32);
|
||||||
const fileURL = getFileURL(attachment?.asset_url ?? "");
|
const fileURL = getFileURL(attachment?.asset_url ?? "");
|
||||||
|
const previewURL = appendSearchParam(fileURL, "preview", "true");
|
||||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||||
|
const [isThumbnailError, setIsThumbnailError] = useState(false);
|
||||||
const menuItems: TContextMenuItem[] = [
|
const menuItems: TContextMenuItem[] = [
|
||||||
{
|
{
|
||||||
key: "delete",
|
key: "delete",
|
||||||
|
|
@ -93,19 +103,23 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative h-full w-28 flex-shrink-0 overflow-hidden bg-surface-1">
|
<div className="relative h-full w-28 flex-shrink-0 overflow-hidden bg-surface-1">
|
||||||
{previewType === "image" && fileURL ? (
|
{previewType === "image" && previewURL && !isThumbnailError ? (
|
||||||
<img src={fileURL} alt={fullFileName} className="h-full w-full object-cover" loading="lazy" />
|
<img
|
||||||
) : previewType === "video" && fileURL ? (
|
src={previewURL}
|
||||||
|
alt={fullFileName}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => setIsThumbnailError(true)}
|
||||||
|
/>
|
||||||
|
) : previewType === "video" && previewURL ? (
|
||||||
<>
|
<>
|
||||||
<video src={fileURL} className="h-full w-full object-cover" muted playsInline preload="metadata" />
|
<video src={previewURL} className="h-full w-full object-cover" muted playsInline preload="metadata" />
|
||||||
<div className="absolute inset-0 grid place-items-center bg-black/20 text-white">
|
<div className="absolute inset-0 grid place-items-center bg-black/20 text-white">
|
||||||
<Play className="size-6 fill-current" />
|
<Play className="size-6 fill-current" />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : previewType === "pdf" && fileURL ? (
|
) : previewType === "pdf" && previewURL ? (
|
||||||
<div className="flex h-full w-full items-center justify-center bg-white text-red-500">
|
<IssueAttachmentPdfThumbnail fileURL={previewURL} />
|
||||||
<FileText className="size-9" />
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
{previewType === "file" ? fileIcon : <ImageIcon className="size-8 text-tertiary" />}
|
{previewType === "file" ? fileIcon : <ImageIcon className="size-8 text-tertiary" />}
|
||||||
|
|
@ -154,10 +168,10 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
isOpen={isPreviewOpen}
|
isOpen={isPreviewOpen}
|
||||||
handleClose={() => setIsPreviewOpen(false)}
|
handleClose={() => setIsPreviewOpen(false)}
|
||||||
position={EModalPosition.CENTER}
|
position={EModalPosition.CENTER}
|
||||||
width={EModalWidth.VIXL}
|
width={EModalWidth.VIIXL}
|
||||||
className="overflow-hidden border border-subtle bg-surface-1"
|
className="h-[calc(100vh-4rem)] max-w-[calc(100vw-4rem)] overflow-hidden border border-subtle bg-surface-1"
|
||||||
>
|
>
|
||||||
<div className="relative min-h-[55vh] bg-surface-1 p-4">
|
<div className="relative flex h-full min-h-0 flex-col bg-surface-1 p-4">
|
||||||
<div className="absolute top-4 right-4 z-10 flex items-center gap-2">
|
<div className="absolute top-4 right-4 z-10 flex items-center gap-2">
|
||||||
{fileURL && (
|
{fileURL && (
|
||||||
<a
|
<a
|
||||||
|
|
@ -180,27 +194,26 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pr-24">
|
<div className="flex-shrink-0 pr-24">
|
||||||
<div className="text-15 font-semibold text-primary">{fullFileName}</div>
|
<div className="text-15 font-semibold text-primary">{fullFileName}</div>
|
||||||
<div className="mt-1 text-12 text-tertiary">{convertBytesToSize(attachment.attributes.size)}</div>
|
<div className="mt-1 text-12 text-tertiary">{convertBytesToSize(attachment.attributes.size)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex h-[70vh] max-h-[70vh] items-center justify-center overflow-hidden rounded-2xl bg-black/20">
|
<div className="mt-4 min-h-0 flex-1 overflow-hidden rounded-2xl bg-black/20">
|
||||||
{previewType === "image" && fileURL ? (
|
{previewType === "image" && previewURL ? (
|
||||||
<img src={fileURL} alt={fullFileName} className="max-h-full max-w-full object-contain" />
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
) : previewType === "video" && fileURL ? (
|
<img src={previewURL} alt={fullFileName} className="max-h-full max-w-full object-contain" />
|
||||||
<video src={fileURL} className="max-h-full max-w-full" controls autoPlay>
|
</div>
|
||||||
<track kind="captions" />
|
) : previewType === "video" && previewURL ? (
|
||||||
</video>
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
) : previewType === "pdf" && fileURL ? (
|
<video src={previewURL} className="max-h-full max-w-full" controls autoPlay>
|
||||||
<iframe
|
<track kind="captions" />
|
||||||
title={fullFileName}
|
</video>
|
||||||
src={fileURL}
|
</div>
|
||||||
sandbox="allow-downloads allow-same-origin"
|
) : previewType === "pdf" && previewURL ? (
|
||||||
className="h-full w-full rounded-2xl bg-white"
|
<IssueAttachmentPdfPreview fileURL={previewURL} />
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-4 p-8 text-center">
|
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
|
||||||
<div className="grid size-20 place-items-center rounded-3xl bg-surface-2">{fileIcon}</div>
|
<div className="grid size-20 place-items-center rounded-3xl bg-surface-2">{fileIcon}</div>
|
||||||
<div className="max-w-md text-14 text-secondary">
|
<div className="max-w-md text-14 text-secondary">
|
||||||
Предпросмотр для этого формата недоступен. Файл можно скачать или открыть в новой вкладке.
|
Предпросмотр для этого формата недоступен. Файл можно скачать или открыть в новой вкладке.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,367 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* See the LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { FileText, FileWarning, Loader2 } from "lucide-react";
|
||||||
|
import type { PDFDocumentProxy } from "pdfjs-dist/types/src/display/api";
|
||||||
|
|
||||||
|
type PdfLoadingTask = {
|
||||||
|
destroy?: () => void;
|
||||||
|
promise: Promise<PDFDocumentProxy>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PdfRenderTask = {
|
||||||
|
cancel?: () => void;
|
||||||
|
promise: Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PdfViewerProps = {
|
||||||
|
fileURL: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PdfPageCanvasProps = {
|
||||||
|
pageNumber: number;
|
||||||
|
pdfDocument: PDFDocumentProxy;
|
||||||
|
renderWidth: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PdfThumbnailProps = {
|
||||||
|
fileURL: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_PREVIEW_PAGE_WIDTH = 1040;
|
||||||
|
const MIN_PREVIEW_PAGE_WIDTH = 360;
|
||||||
|
|
||||||
|
const configurePdfJs = async () => {
|
||||||
|
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/build/pdf.worker.mjs", import.meta.url).toString();
|
||||||
|
return pdfjs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPdfDocument = async (fileURL: string, signal?: AbortSignal) => {
|
||||||
|
const pdfjs = await configurePdfJs();
|
||||||
|
let networkTask: PdfLoadingTask | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
networkTask = pdfjs.getDocument({ url: fileURL, withCredentials: true });
|
||||||
|
return {
|
||||||
|
document: await networkTask.promise,
|
||||||
|
loadingTask: networkTask,
|
||||||
|
};
|
||||||
|
} catch (networkError) {
|
||||||
|
await networkTask?.destroy?.();
|
||||||
|
if (signal?.aborted) throw networkError;
|
||||||
|
|
||||||
|
const response = await fetch(fileURL, { credentials: "include", signal });
|
||||||
|
if (!response.ok) throw new Error(`PDF attachment request failed with ${response.status}`);
|
||||||
|
|
||||||
|
const pdfData = new Uint8Array(await response.arrayBuffer());
|
||||||
|
const bytesTask = pdfjs.getDocument({ data: pdfData });
|
||||||
|
return {
|
||||||
|
document: await bytesTask.promise,
|
||||||
|
loadingTask: bytesTask,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOutputScale = () => Math.max(window.devicePixelRatio || 1, 1);
|
||||||
|
|
||||||
|
const PdfPageCanvas = (props: PdfPageCanvasProps) => {
|
||||||
|
const { pageNumber, pdfDocument, renderWidth } = props;
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const [isRendering, setIsRendering] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current || renderWidth <= 0) return;
|
||||||
|
|
||||||
|
let isCancelled = false;
|
||||||
|
let renderTask: PdfRenderTask | undefined;
|
||||||
|
|
||||||
|
const renderPage = async () => {
|
||||||
|
setIsRendering(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await pdfDocument.getPage(pageNumber);
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const context = canvas?.getContext("2d");
|
||||||
|
if (!canvas || !context) return;
|
||||||
|
|
||||||
|
const viewport = page.getViewport({ scale: 1 });
|
||||||
|
const scale = renderWidth / viewport.width;
|
||||||
|
const scaledViewport = page.getViewport({ scale });
|
||||||
|
const outputScale = getOutputScale();
|
||||||
|
|
||||||
|
canvas.width = Math.floor(scaledViewport.width * outputScale);
|
||||||
|
canvas.height = Math.floor(scaledViewport.height * outputScale);
|
||||||
|
canvas.style.width = `${Math.floor(scaledViewport.width)}px`;
|
||||||
|
canvas.style.height = `${Math.floor(scaledViewport.height)}px`;
|
||||||
|
context.setTransform(outputScale, 0, 0, outputScale, 0, 0);
|
||||||
|
context.clearRect(0, 0, scaledViewport.width, scaledViewport.height);
|
||||||
|
|
||||||
|
renderTask = page.render({ canvasContext: context, viewport: scaledViewport });
|
||||||
|
await renderTask.promise;
|
||||||
|
} finally {
|
||||||
|
if (!isCancelled) setIsRendering(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
renderPage().catch((error) => {
|
||||||
|
if ((error as { name?: string })?.name !== "RenderingCancelledException") {
|
||||||
|
console.error("Error in rendering PDF attachment page:", error);
|
||||||
|
}
|
||||||
|
if (!isCancelled) setIsRendering(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
renderTask?.cancel?.();
|
||||||
|
};
|
||||||
|
}, [pageNumber, pdfDocument, renderWidth]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex w-full justify-center">
|
||||||
|
{isRendering && (
|
||||||
|
<div className="absolute inset-0 z-10 grid place-items-center rounded-sm bg-black/20 text-white">
|
||||||
|
<Loader2 className="size-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<canvas ref={canvasRef} className="h-auto max-w-full rounded-sm bg-white shadow-[0_18px_60px_rgba(0,0,0,0.45)]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IssueAttachmentPdfThumbnail = (props: PdfThumbnailProps) => {
|
||||||
|
const { fileURL } = props;
|
||||||
|
const frameRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const pdfDocumentRef = useRef<PDFDocumentProxy | null>(null);
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [renderTick, setRenderTick] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const frame = frameRef.current;
|
||||||
|
if (!frame) return;
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => setRenderTick((current) => current + 1));
|
||||||
|
resizeObserver.observe(frame);
|
||||||
|
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fileURL || !frameRef.current || !canvasRef.current) return;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
let isCancelled = false;
|
||||||
|
let loadingTask: PdfLoadingTask | undefined;
|
||||||
|
let renderTask: PdfRenderTask | undefined;
|
||||||
|
|
||||||
|
const renderThumbnail = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setHasError(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loaded = await loadPdfDocument(fileURL, controller.signal);
|
||||||
|
loadingTask = loaded.loadingTask;
|
||||||
|
const document = loaded.document;
|
||||||
|
|
||||||
|
if (isCancelled) {
|
||||||
|
document.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfDocumentRef.current = document;
|
||||||
|
|
||||||
|
const page = await document.getPage(1);
|
||||||
|
if (isCancelled) return;
|
||||||
|
|
||||||
|
const frame = frameRef.current;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const context = canvas?.getContext("2d");
|
||||||
|
if (!frame || !canvas || !context) return;
|
||||||
|
|
||||||
|
const frameWidth = Math.max(frame.clientWidth, 1);
|
||||||
|
const frameHeight = Math.max(frame.clientHeight, 1);
|
||||||
|
const viewport = page.getViewport({ scale: 1 });
|
||||||
|
const scale = Math.max(frameWidth / viewport.width, frameHeight / viewport.height);
|
||||||
|
const scaledViewport = page.getViewport({ scale });
|
||||||
|
const outputScale = getOutputScale();
|
||||||
|
|
||||||
|
canvas.width = Math.floor(scaledViewport.width * outputScale);
|
||||||
|
canvas.height = Math.floor(scaledViewport.height * outputScale);
|
||||||
|
canvas.style.width = `${Math.floor(scaledViewport.width)}px`;
|
||||||
|
canvas.style.height = `${Math.floor(scaledViewport.height)}px`;
|
||||||
|
context.setTransform(outputScale, 0, 0, outputScale, 0, 0);
|
||||||
|
context.clearRect(0, 0, scaledViewport.width, scaledViewport.height);
|
||||||
|
|
||||||
|
renderTask = page.render({ canvasContext: context, viewport: scaledViewport });
|
||||||
|
await renderTask.promise;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCancelled && (error as { name?: string })?.name !== "RenderingCancelledException") {
|
||||||
|
console.error("Error in rendering PDF attachment thumbnail:", error);
|
||||||
|
setHasError(true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!isCancelled) setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
renderThumbnail();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
controller.abort();
|
||||||
|
renderTask?.cancel?.();
|
||||||
|
loadingTask?.destroy?.();
|
||||||
|
pdfDocumentRef.current?.destroy();
|
||||||
|
pdfDocumentRef.current = null;
|
||||||
|
};
|
||||||
|
}, [fileURL, renderTick]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={frameRef} className="relative h-full w-full overflow-hidden bg-white">
|
||||||
|
{!hasError && (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="absolute top-1/2 left-1/2 max-w-none -translate-x-1/2 -translate-y-1/2 bg-white"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(isLoading || hasError) && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-white text-red-500">
|
||||||
|
{isLoading ? <Loader2 className="size-7 animate-spin" /> : <FileText className="size-9" />}
|
||||||
|
<span className="rounded-full bg-red-500 px-2 py-0.5 text-10 font-semibold text-white">PDF</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IssueAttachmentPdfPreview = (props: PdfViewerProps) => {
|
||||||
|
const { fileURL } = props;
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const pdfDocumentRef = useRef<PDFDocumentProxy | null>(null);
|
||||||
|
const [pdfDocument, setPdfDocument] = useState<PDFDocumentProxy | null>(null);
|
||||||
|
const [pageCount, setPageCount] = useState(0);
|
||||||
|
const [renderWidth, setRenderWidth] = useState(MIN_PREVIEW_PAGE_WIDTH);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
|
||||||
|
const pageNumbers = useMemo(
|
||||||
|
() => Array.from({ length: pageCount }, (_, index) => index + 1),
|
||||||
|
[pageCount]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fileURL) return;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
let isCancelled = false;
|
||||||
|
let loadingTask: PdfLoadingTask | undefined;
|
||||||
|
|
||||||
|
const loadDocument = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setHasError(false);
|
||||||
|
setPdfDocument(null);
|
||||||
|
setPageCount(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loaded = await loadPdfDocument(fileURL, controller.signal);
|
||||||
|
loadingTask = loaded.loadingTask;
|
||||||
|
const document = loaded.document;
|
||||||
|
|
||||||
|
if (isCancelled) {
|
||||||
|
document.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pdfDocumentRef.current = document;
|
||||||
|
setPdfDocument(document);
|
||||||
|
setPageCount(document.numPages);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCancelled) {
|
||||||
|
console.error("Error in loading PDF attachment preview:", error);
|
||||||
|
setHasError(true);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!isCancelled) setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDocument();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true;
|
||||||
|
controller.abort();
|
||||||
|
loadingTask?.destroy?.();
|
||||||
|
pdfDocumentRef.current?.destroy();
|
||||||
|
pdfDocumentRef.current = null;
|
||||||
|
};
|
||||||
|
}, [fileURL]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const updateRenderWidth = () => {
|
||||||
|
const width = Math.min(Math.max(container.clientWidth - 64, MIN_PREVIEW_PAGE_WIDTH), MAX_PREVIEW_PAGE_WIDTH);
|
||||||
|
setRenderWidth(width);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateRenderWidth();
|
||||||
|
const resizeObserver = new ResizeObserver(updateRenderWidth);
|
||||||
|
resizeObserver.observe(container);
|
||||||
|
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-8 text-center">
|
||||||
|
<div className="text-red-400 grid size-20 place-items-center rounded-3xl bg-surface-2">
|
||||||
|
<FileWarning className="size-10" />
|
||||||
|
</div>
|
||||||
|
<div className="max-w-md text-14 text-secondary">
|
||||||
|
PDF не удалось открыть в предпросмотре. Проверьте загрузку файла или скачайте документ.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex h-full w-full min-w-0 min-h-0 flex-col bg-[#202020]">
|
||||||
|
<div className="flex flex-shrink-0 items-center justify-center border-b border-white/10 bg-surface-1/90 px-4 py-3 text-12 font-semibold text-secondary">
|
||||||
|
{isLoading ? "Загрузка PDF" : `${pageCount} стр.`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={containerRef} className="relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute inset-0 z-10 grid place-items-center bg-black/25 text-white">
|
||||||
|
<div className="flex items-center gap-2 rounded-full bg-black/45 px-4 py-2 text-13 font-medium backdrop-blur-md">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Открываем PDF
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pdfDocument && (
|
||||||
|
<div className="mx-auto flex w-full max-w-[1120px] flex-col items-center gap-6 px-8 py-8">
|
||||||
|
{pageNumbers.map((pageNumber) => (
|
||||||
|
<PdfPageCanvas
|
||||||
|
key={`${fileURL}-${pageNumber}-${renderWidth}`}
|
||||||
|
pageNumber={pageNumber}
|
||||||
|
pdfDocument={pdfDocument}
|
||||||
|
renderWidth={renderWidth}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -27,7 +27,7 @@ export const IssueAttachmentUpload = observer(function IssueAttachmentUpload(pro
|
||||||
// states
|
// states
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
// file size
|
// file size
|
||||||
const { maxFileSize } = useFileSize();
|
const { fileSizeLimitEnabled, maxFileSize } = useFileSize();
|
||||||
|
|
||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
(acceptedFiles: File[]) => {
|
(acceptedFiles: File[]) => {
|
||||||
|
|
@ -42,13 +42,13 @@ export const IssueAttachmentUpload = observer(function IssueAttachmentUpload(pro
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({
|
const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({
|
||||||
onDrop,
|
onDrop,
|
||||||
maxSize: maxFileSize,
|
maxSize: fileSizeLimitEnabled ? maxFileSize : undefined,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
disabled: isLoading || disabled,
|
disabled: isLoading || disabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fileError =
|
const fileError =
|
||||||
fileRejections.length > 0
|
fileRejections.length > 0 && fileSizeLimitEnabled
|
||||||
? t("attachment_upload.invalid_file_type_or_size", { size: maxFileSize / 1024 / 1024 })
|
? t("attachment_upload.invalid_file_type_or_size", { size: maxFileSize / 1024 / 1024 })
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export const IssueAttachmentActionButton = observer(function IssueAttachmentActi
|
||||||
// store hooks
|
// store hooks
|
||||||
const { setLastWidgetAction, fetchActivities } = useIssueDetail(issueServiceType);
|
const { setLastWidgetAction, fetchActivities } = useIssueDetail(issueServiceType);
|
||||||
// file size
|
// file size
|
||||||
const { maxFileSize } = useFileSize();
|
const { fileSizeLimitEnabled, maxFileSize } = useFileSize();
|
||||||
// operations
|
// operations
|
||||||
const { operations: attachmentOperations } = useAttachmentOperations(
|
const { operations: attachmentOperations } = useAttachmentOperations(
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
|
|
@ -77,19 +77,21 @@ export const IssueAttachmentActionButton = observer(function IssueAttachmentActi
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.ERROR,
|
type: TOAST_TYPE.ERROR,
|
||||||
title: "Error!",
|
title: "Error!",
|
||||||
message:
|
message:
|
||||||
totalAttachedFiles > 1
|
totalAttachedFiles > 1
|
||||||
? "Only one file can be uploaded at a time."
|
? "Only one file can be uploaded at a time."
|
||||||
: `File must be of ${maxFileSize / 1024 / 1024}MB or less in size.`,
|
: fileSizeLimitEnabled
|
||||||
|
? `File must be of ${maxFileSize / 1024 / 1024}MB or less in size.`
|
||||||
|
: "File could not be attached. Try uploading again.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
[attachmentOperations, maxFileSize, workspaceSlug, handleFetchPropertyActivities, setLastWidgetAction]
|
[attachmentOperations, fileSizeLimitEnabled, maxFileSize, workspaceSlug, handleFetchPropertyActivities, setLastWidgetAction]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { getRootProps, getInputProps } = useDropzone({
|
const { getRootProps, getInputProps } = useDropzone({
|
||||||
onDrop,
|
onDrop,
|
||||||
maxSize: maxFileSize,
|
maxSize: fileSizeLimitEnabled ? maxFileSize : undefined,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
disabled: isLoading || disabled,
|
disabled: isLoading || disabled,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useParams, usePathname } from "next/navigation";
|
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||||
import { SettingsIcon } from "lucide-react";
|
import { SettingsIcon } from "lucide-react";
|
||||||
import { ContextMenu } from "@plane/propel/context-menu";
|
import { ContextMenu } from "@plane/propel/context-menu";
|
||||||
import { CheckIcon } from "@plane/propel/icons";
|
import { CheckIcon } from "@plane/propel/icons";
|
||||||
|
|
@ -25,11 +25,17 @@ export const AppRailRoot = observer(() => {
|
||||||
// router
|
// router
|
||||||
const { workspaceSlug, projectId } = useParams();
|
const { workspaceSlug, projectId } = useParams();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
// preferences
|
// preferences
|
||||||
const { preferences, updateDisplayMode } = useAppRailPreferences();
|
const { preferences, updateDisplayMode } = useAppRailPreferences();
|
||||||
const { isCollapsed, toggleAppRail } = useAppRailVisibility();
|
const { isCollapsed, toggleAppRail } = useAppRailVisibility();
|
||||||
// derived values
|
// derived values
|
||||||
const isWorkspaceSettingsPath = pathname.includes(`/${workspaceSlug}/settings`) && !projectId;
|
const settingsModalSearchParams = new URLSearchParams(searchParams?.toString());
|
||||||
|
settingsModalSearchParams.set("workspaceSettings", "general");
|
||||||
|
const workspaceSettingsHref = `${pathname || `/${workspaceSlug}`}?${settingsModalSearchParams.toString()}`;
|
||||||
|
const isWorkspaceSettingsPath =
|
||||||
|
(pathname.includes(`/${workspaceSlug}/settings`) && !projectId) ||
|
||||||
|
searchParams?.get("workspaceSettings") === "general";
|
||||||
const showLabel = preferences.displayMode === "icon_with_label";
|
const showLabel = preferences.displayMode === "icon_with_label";
|
||||||
const railWidth = showLabel ? "3.75rem" : "3rem";
|
const railWidth = showLabel ? "3.75rem" : "3rem";
|
||||||
|
|
||||||
|
|
@ -57,7 +63,7 @@ export const AppRailRoot = observer(() => {
|
||||||
item={{
|
item={{
|
||||||
label: "Settings",
|
label: "Settings",
|
||||||
icon: <SettingsIcon className="size-5" />,
|
icon: <SettingsIcon className="size-5" />,
|
||||||
href: `/${workspaceSlug}/settings`,
|
href: workspaceSettingsHref,
|
||||||
isActive: isWorkspaceSettingsPath,
|
isActive: isWorkspaceSettingsPath,
|
||||||
showLabel,
|
showLabel,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { useEffect, useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
// Plane Imports
|
// Plane Imports
|
||||||
import { ORGANIZATION_SIZE, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
import { MAX_FILE_SIZE, ORGANIZATION_SIZE, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||||
import { useTranslation } from "@plane/i18n";
|
import { useTranslation } from "@plane/i18n";
|
||||||
import { Button } from "@plane/propel/button";
|
import { Button } from "@plane/propel/button";
|
||||||
import { EditIcon } from "@plane/propel/icons";
|
import { EditIcon } from "@plane/propel/icons";
|
||||||
|
|
@ -32,6 +32,21 @@ const defaultValues: Partial<IWorkspace> = {
|
||||||
organization_size: "2-10",
|
organization_size: "2-10",
|
||||||
logo_url: null,
|
logo_url: null,
|
||||||
timezone: "UTC",
|
timezone: "UTC",
|
||||||
|
storage_file_size_limit_enabled: true,
|
||||||
|
storage_file_size_limit: MAX_FILE_SIZE,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BYTES_IN_MB = 1024 * 1024;
|
||||||
|
|
||||||
|
const bytesToMegabytes = (value?: number) => {
|
||||||
|
if (!value || value < 1) return MAX_FILE_SIZE / BYTES_IN_MB;
|
||||||
|
return Number((value / BYTES_IN_MB).toFixed(2));
|
||||||
|
};
|
||||||
|
|
||||||
|
const megabytesToBytes = (value: string) => {
|
||||||
|
const numericValue = Number(value);
|
||||||
|
if (!Number.isFinite(numericValue) || numericValue <= 0) return 0;
|
||||||
|
return Math.round(numericValue * BYTES_IN_MB);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WorkspaceDetails = observer(function WorkspaceDetails() {
|
export const WorkspaceDetails = observer(function WorkspaceDetails() {
|
||||||
|
|
@ -55,6 +70,7 @@ export const WorkspaceDetails = observer(function WorkspaceDetails() {
|
||||||
});
|
});
|
||||||
// derived values
|
// derived values
|
||||||
const workspaceLogo = watch("logo_url");
|
const workspaceLogo = watch("logo_url");
|
||||||
|
const storageFileSizeLimitEnabled = watch("storage_file_size_limit_enabled") ?? true;
|
||||||
|
|
||||||
const onSubmit = async (formData: IWorkspace) => {
|
const onSubmit = async (formData: IWorkspace) => {
|
||||||
if (!currentWorkspace) return;
|
if (!currentWorkspace) return;
|
||||||
|
|
@ -65,6 +81,8 @@ export const WorkspaceDetails = observer(function WorkspaceDetails() {
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
organization_size: formData.organization_size,
|
organization_size: formData.organization_size,
|
||||||
timezone: formData.timezone,
|
timezone: formData.timezone,
|
||||||
|
storage_file_size_limit_enabled: formData.storage_file_size_limit_enabled,
|
||||||
|
storage_file_size_limit: formData.storage_file_size_limit || MAX_FILE_SIZE,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -279,6 +297,96 @@ export const WorkspaceDetails = observer(function WorkspaceDetails() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="nodedc-settings-card flex flex-col gap-5 px-5 py-5">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<h3 className="text-15 font-semibold text-primary">Хранилище</h3>
|
||||||
|
<p className="max-w-3xl text-12 leading-5 text-tertiary">
|
||||||
|
Управляет ограничением веса файлов для вложений в задачах этого workspace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="storage_file_size_limit_enabled"
|
||||||
|
render={({ field: { value, onChange } }) => {
|
||||||
|
const isChecked = value ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between gap-4 rounded-[1.25rem] bg-white/5 px-4 py-3 text-left transition",
|
||||||
|
"hover:bg-white/8 focus-visible:bg-white/8",
|
||||||
|
!isAdmin && "cursor-not-allowed opacity-70"
|
||||||
|
)}
|
||||||
|
onClick={() => isAdmin && onChange(!isChecked)}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid size-4 flex-shrink-0 place-items-center rounded-full transition",
|
||||||
|
isChecked ? "bg-[rgb(var(--nodedc-accent-rgb))]" : "bg-white/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isChecked && (
|
||||||
|
<span className="size-1.5 rounded-full bg-[rgb(var(--nodedc-on-accent-rgb))]" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-13 font-semibold text-primary">Ограничивать размер вложений</span>
|
||||||
|
<span className="mt-0.5 block text-12 leading-5 text-tertiary">
|
||||||
|
{isChecked
|
||||||
|
? "В окне загрузки будет показан максимальный размер файла."
|
||||||
|
: "Лимит снят: окно загрузки не будет ограничивать размер файла."}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="hidden flex-shrink-0 rounded-full bg-white/6 px-3 py-1 text-11 font-semibold text-secondary sm:block">
|
||||||
|
{isChecked ? "Лимит включен" : "Без лимита"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{storageFileSizeLimitEnabled && (
|
||||||
|
<div className="grid max-w-xl grid-cols-1 gap-2.5">
|
||||||
|
<h4 className="text-body-sm-medium text-tertiary">Максимальный размер файла, МБ</h4>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="storage_file_size_limit"
|
||||||
|
rules={{
|
||||||
|
validate: (value) =>
|
||||||
|
!storageFileSizeLimitEnabled ||
|
||||||
|
(Number(value) > 0 && Number.isFinite(Number(value))) ||
|
||||||
|
"Укажите лимит больше 0 МБ",
|
||||||
|
}}
|
||||||
|
render={({ field: { value, onChange, ref } }) => (
|
||||||
|
<Input
|
||||||
|
id="storage_file_size_limit"
|
||||||
|
name="storage_file_size_limit"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
value={bytesToMegabytes(value)}
|
||||||
|
onChange={(event) => onChange(megabytesToBytes(event.target.value))}
|
||||||
|
ref={ref}
|
||||||
|
hasError={Boolean(errors.storage_file_size_limit)}
|
||||||
|
placeholder="Например, 25"
|
||||||
|
className="nodedc-settings-input w-full"
|
||||||
|
disabled={!isAdmin}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errors.storage_file_size_limit && (
|
||||||
|
<p className="text-caption-sm-regular text-danger-primary">
|
||||||
|
{errors.storage_file_size_limit.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="flex items-center justify-between py-1">
|
<div className="flex items-center justify-between py-1">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* See the LICENSE file for details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { useLocation, useNavigate } from "react-router";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
// plane imports
|
||||||
|
import { ScrollArea } from "@plane/propel/scrollarea";
|
||||||
|
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { WorkspaceDetails } from "@/components/workspace/settings/workspace-details";
|
||||||
|
// hooks
|
||||||
|
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||||
|
|
||||||
|
export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(location.search);
|
||||||
|
const activeModal = searchParams.get("workspaceSettings");
|
||||||
|
const isOpen = activeModal === "general";
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
const nextSearchParams = new URLSearchParams(location.search);
|
||||||
|
nextSearchParams.delete("workspaceSettings");
|
||||||
|
|
||||||
|
navigate(
|
||||||
|
{
|
||||||
|
pathname: location.pathname,
|
||||||
|
search: nextSearchParams.toString() ? `?${nextSearchParams.toString()}` : "",
|
||||||
|
hash: location.hash,
|
||||||
|
},
|
||||||
|
{ replace: true }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalCore
|
||||||
|
isOpen={isOpen}
|
||||||
|
handleClose={handleClose}
|
||||||
|
position={EModalPosition.CENTER}
|
||||||
|
width={EModalWidth.VIIXL}
|
||||||
|
className="h-[88vh] max-h-[920px] overflow-hidden border border-white/8 bg-[rgba(12,12,16,0.94)]"
|
||||||
|
>
|
||||||
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
|
<div className="flex shrink-0 items-center justify-between gap-4 border-b border-white/6 px-6 py-5">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-18 font-semibold text-primary">Настройки workspace</div>
|
||||||
|
<div className="mt-1 truncate text-12 text-tertiary">
|
||||||
|
{currentWorkspace?.name ?? "Workspace"} / основные параметры
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="grid size-10 flex-shrink-0 place-items-center rounded-full bg-white/6 text-secondary transition hover:bg-white/10 hover:text-primary"
|
||||||
|
aria-label="Закрыть настройки workspace"
|
||||||
|
>
|
||||||
|
<X className="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea scrollType="hover" orientation="vertical" size="sm" className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<div className="mx-auto w-full max-w-[74rem] px-5 py-6 lg:px-8">
|
||||||
|
<WorkspaceDetails />
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</ModalCore>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||||
import { Settings, UserPlus } from "lucide-react";
|
import { Settings, UserPlus } from "lucide-react";
|
||||||
import { Menu } from "@headlessui/react";
|
import { Menu } from "@headlessui/react";
|
||||||
// plane imports
|
// plane imports
|
||||||
|
|
@ -29,8 +29,13 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
|
||||||
const { workspace, activeWorkspace, handleItemClick, handleWorkspaceNavigation, handleClose } = props;
|
const { workspace, activeWorkspace, handleItemClick, handleWorkspaceNavigation, handleClose } = props;
|
||||||
// router
|
// router
|
||||||
const { workspaceSlug } = useParams();
|
const { workspaceSlug } = useParams();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
// hooks
|
// hooks
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const settingsModalSearchParams = new URLSearchParams(searchParams?.toString());
|
||||||
|
settingsModalSearchParams.set("workspaceSettings", "general");
|
||||||
|
const workspaceSettingsHref = `${pathname || `/${workspace.slug}`}?${settingsModalSearchParams.toString()}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -93,7 +98,7 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
|
||||||
<div className="mt-2 mb-1 grid grid-cols-2 gap-3">
|
<div className="mt-2 mb-1 grid grid-cols-2 gap-3">
|
||||||
{[EUserPermissions.ADMIN, EUserPermissions.MEMBER].includes(workspace?.role) && (
|
{[EUserPermissions.ADMIN, EUserPermissions.MEMBER].includes(workspace?.role) && (
|
||||||
<Link
|
<Link
|
||||||
href={`/${workspace.slug}/settings`}
|
href={workspaceSettingsHref}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleClose();
|
handleClose();
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { MoreHorizontal, ArchiveIcon, Settings } from "lucide-react";
|
import { MoreHorizontal, ArchiveIcon, Settings } from "lucide-react";
|
||||||
import { Disclosure } from "@headlessui/react";
|
import { Disclosure } from "@headlessui/react";
|
||||||
// plane imports
|
// plane imports
|
||||||
|
|
@ -32,12 +32,17 @@ export const SidebarWorkspaceMenuHeader = observer(function SidebarWorkspaceMenu
|
||||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||||
// hooks
|
// hooks
|
||||||
const { workspaceSlug } = useParams();
|
const { workspaceSlug } = useParams();
|
||||||
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const { allowPermissions } = useUserPermissions();
|
const { allowPermissions } = useUserPermissions();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// TODO: fix types
|
// TODO: fix types
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const isAdmin = allowPermissions([EUserWorkspaceRoles.ADMIN] as any, EUserPermissionsLevel.WORKSPACE);
|
const isAdmin = allowPermissions([EUserWorkspaceRoles.ADMIN] as any, EUserPermissionsLevel.WORKSPACE);
|
||||||
|
const settingsModalSearchParams = new URLSearchParams(searchParams?.toString());
|
||||||
|
settingsModalSearchParams.set("workspaceSettings", "general");
|
||||||
|
const workspaceSettingsHref = `${pathname || `/${workspaceSlug}`}?${settingsModalSearchParams.toString()}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group/workspace-button mt-2.5 flex rounded-sm bg-surface-1 px-2 hover:bg-surface-2">
|
<div className="group/workspace-button mt-2.5 flex rounded-sm bg-surface-1 px-2 hover:bg-surface-2">
|
||||||
|
|
@ -74,7 +79,7 @@ export const SidebarWorkspaceMenuHeader = observer(function SidebarWorkspaceMenu
|
||||||
key: "settings",
|
key: "settings",
|
||||||
title: t("settings"),
|
title: t("settings"),
|
||||||
icon: Settings,
|
icon: Settings,
|
||||||
action: () => router.push(`/${workspaceSlug}/settings`),
|
action: () => router.push(workspaceSettingsHref),
|
||||||
shouldRender: isAdmin,
|
shouldRender: isAdmin,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export class FileUploadService extends APIService {
|
||||||
if (axios.isCancel(error)) {
|
if (axios.isCancel(error)) {
|
||||||
console.log(error.message);
|
console.log(error.message);
|
||||||
} else {
|
} else {
|
||||||
throw error?.response?.data;
|
throw error?.response?.data ?? error;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,16 +55,32 @@ export class IssueAttachmentService extends APIService {
|
||||||
.then(async (response) => {
|
.then(async (response) => {
|
||||||
const signedURLResponse: TIssueAttachmentUploadResponse = response?.data;
|
const signedURLResponse: TIssueAttachmentUploadResponse = response?.data;
|
||||||
const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file);
|
const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file);
|
||||||
await this.fileUploadService.uploadFile(
|
let uploadError: unknown;
|
||||||
signedURLResponse.upload_data.url,
|
try {
|
||||||
fileUploadPayload,
|
await this.fileUploadService.uploadFile(
|
||||||
uploadProgressHandler
|
signedURLResponse.upload_data.url,
|
||||||
);
|
fileUploadPayload,
|
||||||
await this.updateIssueAttachmentUploadStatus(workspaceSlug, projectId, issueId, signedURLResponse.asset_id);
|
uploadProgressHandler
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
uploadError = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.updateIssueAttachmentUploadStatus(workspaceSlug, projectId, issueId, signedURLResponse.asset_id);
|
||||||
|
} catch (error) {
|
||||||
|
await this.deleteIssueAttachment(workspaceSlug, projectId, issueId, signedURLResponse.asset_id).catch(
|
||||||
|
(deleteError) => {
|
||||||
|
console.error("Error in cleaning failed issue attachment upload:", deleteError);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
throw uploadError ?? error;
|
||||||
|
}
|
||||||
|
|
||||||
return signedURLResponse.attachment;
|
return signedURLResponse.attachment;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
throw error?.response?.data;
|
throw error?.response?.data ?? error;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,7 +90,7 @@ export class IssueAttachmentService extends APIService {
|
||||||
)
|
)
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
throw error?.response?.data;
|
throw error?.response?.data ?? error;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,7 +105,7 @@ export class IssueAttachmentService extends APIService {
|
||||||
)
|
)
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
throw error?.response?.data;
|
throw error?.response?.data ?? error;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* See the LICENSE file for details.
|
* See the LICENSE file for details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { uniq, pull, set, debounce, update, concat } from "lodash-es";
|
import { uniq, pull, set, debounce, update } from "lodash-es";
|
||||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||||
import { computedFn } from "mobx-utils";
|
import { computedFn } from "mobx-utils";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
@ -23,6 +23,17 @@ export type TAttachmentUploadStatus = {
|
||||||
type: string;
|
type: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getAttachmentTimestamp = (attachment: TIssueAttachment) =>
|
||||||
|
new Date(attachment.created_at ?? attachment.updated_at ?? 0).getTime();
|
||||||
|
|
||||||
|
const sortAttachmentIdsByNewest = (attachmentIds: string[], attachmentMap: TIssueAttachmentMap): string[] =>
|
||||||
|
[...attachmentIds].sort((firstId, secondId) => {
|
||||||
|
const firstAttachment = attachmentMap[firstId];
|
||||||
|
const secondAttachment = attachmentMap[secondId];
|
||||||
|
if (!firstAttachment || !secondAttachment) return 0;
|
||||||
|
return getAttachmentTimestamp(secondAttachment) - getAttachmentTimestamp(firstAttachment);
|
||||||
|
});
|
||||||
|
|
||||||
export interface IIssueAttachmentStoreActions {
|
export interface IIssueAttachmentStoreActions {
|
||||||
// actions
|
// actions
|
||||||
addAttachments: (issueId: string, attachments: TIssueAttachment[]) => void;
|
addAttachments: (issueId: string, attachments: TIssueAttachment[]) => void;
|
||||||
|
|
@ -119,17 +130,31 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
|
||||||
// actions
|
// actions
|
||||||
addAttachments = (issueId: string, attachments: TIssueAttachment[]) => {
|
addAttachments = (issueId: string, attachments: TIssueAttachment[]) => {
|
||||||
if (attachments && attachments.length > 0) {
|
if (attachments && attachments.length > 0) {
|
||||||
const newAttachmentIds = attachments.map((attachment) => attachment.id);
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
update(this.attachments, [issueId], (attachmentIds = []) => uniq(concat(attachmentIds, newAttachmentIds)));
|
|
||||||
attachments.forEach((attachment) => set(this.attachmentMap, attachment.id, attachment));
|
attachments.forEach((attachment) => set(this.attachmentMap, attachment.id, attachment));
|
||||||
|
update(this.attachments, [issueId], (attachmentIds = []) =>
|
||||||
|
sortAttachmentIdsByNewest(
|
||||||
|
uniq([...attachments.map((attachment) => attachment.id), ...attachmentIds]),
|
||||||
|
this.attachmentMap
|
||||||
|
)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchAttachments = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
fetchAttachments = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||||
const response = await this.issueAttachmentService.getIssueAttachments(workspaceSlug, projectId, issueId);
|
const response = await this.issueAttachmentService.getIssueAttachments(workspaceSlug, projectId, issueId);
|
||||||
this.addAttachments(issueId, response);
|
runInAction(() => {
|
||||||
|
response.forEach((attachment) => set(this.attachmentMap, attachment.id, attachment));
|
||||||
|
set(
|
||||||
|
this.attachments,
|
||||||
|
issueId,
|
||||||
|
sortAttachmentIdsByNewest(
|
||||||
|
response.map((attachment) => attachment.id),
|
||||||
|
this.attachmentMap
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -164,13 +189,23 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response && response.id) {
|
if (response && response.id) {
|
||||||
runInAction(() => {
|
try {
|
||||||
update(this.attachments, [issueId], (attachmentIds = []) => uniq(concat(attachmentIds, [response.id])));
|
runInAction(() => {
|
||||||
set(this.attachmentMap, response.id, response);
|
set(this.attachmentsUploadStatusMap, [issueId, tempId, "progress"], 100);
|
||||||
this.rootIssueStore.issues.updateIssue(issueId, {
|
|
||||||
attachment_count: this.getAttachmentsCountByIssueId(issueId),
|
|
||||||
});
|
});
|
||||||
});
|
const syncedAttachments = await this.fetchAttachments(workspaceSlug, projectId, issueId).catch((error) => {
|
||||||
|
console.error("Error in fetching issue attachments after upload:", error);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
runInAction(() => {
|
||||||
|
this.rootIssueStore.issues.updateIssue(issueId, {
|
||||||
|
attachment_count: this.getAttachmentsCountByIssueId(issueId),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return syncedAttachments?.find((attachment) => attachment.id === response.id) ?? response;
|
||||||
|
} catch (syncError) {
|
||||||
|
console.error("Error in syncing issue attachment after upload:", syncError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|
@ -179,7 +214,7 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
delete this.attachmentsUploadStatusMap[issueId][tempId];
|
if (this.attachmentsUploadStatusMap[issueId]) delete this.attachmentsUploadStatusMap[issueId][tempId];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,14 @@ http {
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ~* ^/assets/.+\.mjs$ {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
types { application/javascript mjs; }
|
||||||
|
default_type application/javascript;
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
location ~* ^/assets/.+\.(js|css|png|jpg|jpeg|gif|svg|webp|ico|woff|woff2)$ {
|
location ~* ^/assets/.+\.(js|css|png|jpg|jpeg|gif|svg|webp|ico|woff|woff2)$ {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
"mobx-react": "catalog:",
|
"mobx-react": "catalog:",
|
||||||
"mobx-utils": "catalog:",
|
"mobx-utils": "catalog:",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
|
"pdfjs-dist": "5.4.296",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-color": "^2.19.3",
|
"react-color": "^2.19.3",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ AWS_S3_ENDPOINT_URL=https://s3.amazonaws.com
|
||||||
AWS_S3_BUCKET_NAME=
|
AWS_S3_BUCKET_NAME=
|
||||||
BUCKET_NAME=
|
BUCKET_NAME=
|
||||||
FILE_SIZE_LIMIT=5242880
|
FILE_SIZE_LIMIT=5242880
|
||||||
|
PROXY_BODY_SIZE_LIMIT=1073741824
|
||||||
|
|
||||||
# Gunicorn Workers
|
# Gunicorn Workers
|
||||||
GUNICORN_WORKERS=1
|
GUNICORN_WORKERS=1
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,8 @@ x-aws-s3-env: &aws-s3-env
|
||||||
|
|
||||||
x-proxy-env: &proxy-env
|
x-proxy-env: &proxy-env
|
||||||
APP_DOMAIN: ${APP_DOMAIN:-localhost}
|
APP_DOMAIN: ${APP_DOMAIN:-localhost}
|
||||||
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
|
FILE_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824}
|
||||||
|
PROXY_BODY_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824}
|
||||||
CERT_EMAIL: ${CERT_EMAIL}
|
CERT_EMAIL: ${CERT_EMAIL}
|
||||||
CERT_ACME_CA: ${CERT_ACME_CA}
|
CERT_ACME_CA: ${CERT_ACME_CA}
|
||||||
CERT_ACME_DNS: ${CERT_ACME_DNS}
|
CERT_ACME_DNS: ${CERT_ACME_DNS}
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ AWS_SECRET_ACCESS_KEY=secret-key
|
||||||
AWS_S3_ENDPOINT_URL=http://plane-minio:9000
|
AWS_S3_ENDPOINT_URL=http://plane-minio:9000
|
||||||
AWS_S3_BUCKET_NAME=uploads
|
AWS_S3_BUCKET_NAME=uploads
|
||||||
FILE_SIZE_LIMIT=5242880
|
FILE_SIZE_LIMIT=5242880
|
||||||
|
PROXY_BODY_SIZE_LIMIT=1073741824
|
||||||
|
|
||||||
# Gunicorn Workers
|
# Gunicorn Workers
|
||||||
GUNICORN_WORKERS=1
|
GUNICORN_WORKERS=1
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,8 @@ services:
|
||||||
- ${LISTEN_HTTP_PORT}:80
|
- ${LISTEN_HTTP_PORT}:80
|
||||||
- ${LISTEN_HTTPS_PORT}:443
|
- ${LISTEN_HTTPS_PORT}:443
|
||||||
environment:
|
environment:
|
||||||
FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880}
|
FILE_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824}
|
||||||
|
PROXY_BODY_SIZE_LIMIT: ${PROXY_BODY_SIZE_LIMIT:-1073741824}
|
||||||
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads}
|
||||||
depends_on:
|
depends_on:
|
||||||
- web
|
- web
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export type TIssueAttachment = {
|
||||||
};
|
};
|
||||||
asset_url: string;
|
asset_url: string;
|
||||||
issue_id: string;
|
issue_id: string;
|
||||||
|
created_at?: string;
|
||||||
// required
|
// required
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
updated_by: string;
|
updated_by: string;
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ export interface IWorkspace {
|
||||||
total_projects?: number;
|
total_projects?: number;
|
||||||
role: number;
|
role: number;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
|
storage_file_size_limit_enabled: boolean;
|
||||||
|
storage_file_size_limit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkspaceLite {
|
export interface IWorkspaceLite {
|
||||||
|
|
|
||||||
|
|
@ -662,6 +662,9 @@ importers:
|
||||||
next-themes:
|
next-themes:
|
||||||
specifier: 0.4.6
|
specifier: 0.4.6
|
||||||
version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
pdfjs-dist:
|
||||||
|
specifier: 5.4.296
|
||||||
|
version: 5.4.296
|
||||||
react:
|
react:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue