diff --git a/HDESIGN-CODE.md b/HDESIGN-CODE.md
index 8e8ab11..23963b2 100644
--- a/HDESIGN-CODE.md
+++ b/HDESIGN-CODE.md
@@ -80,6 +80,16 @@
- единая вертикальная высота для одного класса контролов
- Placeholder и label должны быть читаемы и не прилипать к краям.
+## Чекеры
+- Для бинарных настроек в glass-интерфейсе используется круглый checker в стиле фильтров отображения.
+- Активное состояние:
+ - круг залит `rgb(var(--nodedc-accent-rgb))`
+ - внутри маленькая точка `rgb(var(--nodedc-on-accent-rgb))`
+- Неактивное состояние:
+ - круг на мягком `white/10`
+ - без внешнего outline и без синей browser-рамки
+- Текстовый статус рядом с checker может дублировать состояние, но сам визуальный якорь должен оставаться круглым, а не квадратным checkbox.
+
## Toolbar и верхние панели
- Элементы верхней панели центрируются по одной горизонтальной оси.
- Активный layout/tool mode выделяется кругом акцентного цвета, не квадратной плашкой.
diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml
index 0406ad2..60517af 100644
--- a/docker/docker-compose.yaml
+++ b/docker/docker-compose.yaml
@@ -25,7 +25,8 @@ x-aws-s3-env: &aws-s3-env
x-proxy-env: &proxy-env
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_ACME_CA: ${CERT_ACME_CA}
CERT_ACME_DNS: ${CERT_ACME_DNS}
diff --git a/docker/plane.env b/docker/plane.env
index 16ba3a0..77c0288 100644
--- a/docker/plane.env
+++ b/docker/plane.env
@@ -64,6 +64,7 @@ AWS_SECRET_ACCESS_KEY=secret-key
AWS_S3_ENDPOINT_URL=http://plane-minio:9000
AWS_S3_BUCKET_NAME=uploads
FILE_SIZE_LIMIT=5242880
+PROXY_BODY_SIZE_LIMIT=1073741824
POSTHOG_API_KEY=
POSTHOG_HOST=
INSTANCE_CHANGELOG_URL=
diff --git a/plane-app/docker-compose.yaml b/plane-app/docker-compose.yaml
index 0406ad2..60517af 100644
--- a/plane-app/docker-compose.yaml
+++ b/plane-app/docker-compose.yaml
@@ -25,7 +25,8 @@ x-aws-s3-env: &aws-s3-env
x-proxy-env: &proxy-env
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_ACME_CA: ${CERT_ACME_CA}
CERT_ACME_DNS: ${CERT_ACME_DNS}
diff --git a/plane-app/plane.env b/plane-app/plane.env
index 16ba3a0..77c0288 100644
--- a/plane-app/plane.env
+++ b/plane-app/plane.env
@@ -64,6 +64,7 @@ AWS_SECRET_ACCESS_KEY=secret-key
AWS_S3_ENDPOINT_URL=http://plane-minio:9000
AWS_S3_BUCKET_NAME=uploads
FILE_SIZE_LIMIT=5242880
+PROXY_BODY_SIZE_LIMIT=1073741824
POSTHOG_API_KEY=
POSTHOG_HOST=
INSTANCE_CHANGELOG_URL=
diff --git a/plane-src/apps/api/plane/api/views/asset.py b/plane-src/apps/api/plane/api/views/asset.py
index 88c34c3..318ff87 100644
--- a/plane-src/apps/api/plane/api/views/asset.py
+++ b/plane-src/apps/api/plane/api/views/asset.py
@@ -43,6 +43,7 @@ from plane.utils.openapi import (
asset_docs,
)
from plane.utils.exception_logger import log_exception
+from plane.utils.upload_limits import resolve_workspace_upload_size_limit
class UserAssetEndpoint(BaseAPIView):
@@ -512,9 +513,6 @@ class GenericAssetEndpoint(BaseAPIView):
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
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
return Response(
@@ -525,6 +523,9 @@ class GenericAssetEndpoint(BaseAPIView):
# Get the workspace
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 = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
diff --git a/plane-src/apps/api/plane/api/views/issue.py b/plane-src/apps/api/plane/api/views/issue.py
index 97e8e7c..1d36d7a 100644
--- a/plane-src/apps/api/plane/api/views/issue.py
+++ b/plane-src/apps/api/plane/api/views/issue.py
@@ -86,6 +86,8 @@ from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from .base import BaseAPIView
from plane.utils.host import base_host
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.app.permissions import ROLE
from plane.utils.openapi import (
@@ -1874,8 +1876,6 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
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:
return Response(
{"error": "Invalid file type.", "status": False},
@@ -1885,6 +1885,8 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView):
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
+ size_limit = resolve_workspace_upload_size_limit(workspace, size)
+
# asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
@@ -2100,13 +2102,8 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
- storage = S3Storage(request=request)
- presigned_url = storage.generate_presigned_url(
- object_name=asset.asset.name,
- disposition="attachment",
- filename=asset.attributes.get("name"),
- )
- return HttpResponseRedirect(presigned_url)
+ disposition = "inline" if request.GET.get("preview") == "true" else "attachment"
+ return get_attachment_preview_response(request, asset, disposition=disposition)
@issue_attachment_docs(
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)
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
if not issue_attachment.is_uploaded:
diff --git a/plane-src/apps/api/plane/app/serializers/base.py b/plane-src/apps/api/plane/app/serializers/base.py
index 6457eec..2654858 100644
--- a/plane-src/apps/api/plane/app/serializers/base.py
+++ b/plane-src/apps/api/plane/app/serializers/base.py
@@ -192,6 +192,7 @@ class DynamicBaseSerializer(BaseSerializer):
issue_attachments = FileAsset.objects.filter(
issue_id=issue_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
+ is_uploaded=True,
)
# Serialize issue_attachments and add them to the response
response["issue_attachments"] = IssueAttachmentLiteSerializer(issue_attachments, many=True).data
diff --git a/plane-src/apps/api/plane/app/serializers/workspace.py b/plane-src/apps/api/plane/app/serializers/workspace.py
index 608cdad..49df602 100644
--- a/plane-src/apps/api/plane/app/serializers/workspace.py
+++ b/plane-src/apps/api/plane/app/serializers/workspace.py
@@ -61,6 +61,11 @@ class WorkSpaceSerializer(DynamicBaseSerializer):
)
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:
model = Workspace
fields = "__all__"
diff --git a/plane-src/apps/api/plane/app/views/asset/v2.py b/plane-src/apps/api/plane/app/views/asset/v2.py
index 62c5f84..3d8d052 100644
--- a/plane-src/apps/api/plane/app/views/asset/v2.py
+++ b/plane-src/apps/api/plane/app/views/asset/v2.py
@@ -24,6 +24,7 @@ from plane.app.permissions import allow_permission, ROLE
from plane.utils.cache import invalidate_cache_directly
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from plane.throttles.asset import AssetRateThrottle
+from plane.utils.upload_limits import resolve_workspace_upload_size_limit
class UserAssetsV2Endpoint(BaseAPIView):
@@ -342,12 +343,12 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
- # Get the size limit
- size_limit = min(settings.FILE_SIZE_LIMIT, size)
-
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
+ # Get the size limit
+ size_limit = resolve_workspace_upload_size_limit(workspace, size)
+
# asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
@@ -541,12 +542,12 @@ class ProjectAssetEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
- # Get the size limit
- size_limit = min(settings.FILE_SIZE_LIMIT, size)
-
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
+ # Get the size limit
+ size_limit = resolve_workspace_upload_size_limit(workspace, size)
+
# asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
diff --git a/plane-src/apps/api/plane/app/views/issue/attachment.py b/plane-src/apps/api/plane/app/views/issue/attachment.py
index df027c4..a66b6a8 100644
--- a/plane-src/apps/api/plane/app/views/issue/attachment.py
+++ b/plane-src/apps/api/plane/app/views/issue/attachment.py
@@ -10,7 +10,6 @@ import uuid
from django.utils import timezone
from django.core.serializers.json import DjangoJSONEncoder
from django.conf import settings
-from django.http import HttpResponseRedirect
# Third Party imports
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.bgtasks.storage_metadata_task import get_asset_object_metadata
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):
@@ -86,7 +87,13 @@ class IssueAttachmentEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
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)
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}"
# 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
asset = FileAsset.objects.create(
@@ -179,13 +186,8 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
- storage = S3Storage(request=request)
- presigned_url = storage.generate_presigned_url(
- object_name=asset.asset.name,
- disposition="attachment",
- filename=asset.attributes.get("name"),
- )
- return HttpResponseRedirect(presigned_url)
+ disposition = "inline" if request.GET.get("preview") == "true" else "attachment"
+ return get_attachment_preview_response(request, asset, disposition=disposition)
# Get all the attachments
issue_attachments = FileAsset.objects.filter(
@@ -203,6 +205,11 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
def patch(self, request, slug, project_id, issue_id, pk):
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
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
if not issue_attachment.is_uploaded:
diff --git a/plane-src/apps/api/plane/db/migrations/0126_workspace_storage_limits.py b/plane-src/apps/api/plane/db/migrations/0126_workspace_storage_limits.py
new file mode 100644
index 0000000..f6e3c3e
--- /dev/null
+++ b/plane-src/apps/api/plane/db/migrations/0126_workspace_storage_limits.py
@@ -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),
+ ),
+ ]
diff --git a/plane-src/apps/api/plane/db/models/workspace.py b/plane-src/apps/api/plane/db/models/workspace.py
index 80a3e3e..919e2fe 100644
--- a/plane-src/apps/api/plane/db/models/workspace.py
+++ b/plane-src/apps/api/plane/db/models/workspace.py
@@ -137,6 +137,8 @@ class Workspace(BaseModel):
organization_size = models.CharField(max_length=20, blank=True, null=True)
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
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):
"""Return name of the Workspace"""
diff --git a/plane-src/apps/api/plane/tests/unit/utils/test_upload_limits.py b/plane-src/apps/api/plane/tests/unit/utils/test_upload_limits.py
new file mode 100644
index 0000000..906e729
--- /dev/null
+++ b/plane-src/apps/api/plane/tests/unit/utils/test_upload_limits.py
@@ -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
diff --git a/plane-src/apps/api/plane/utils/attachment_preview.py b/plane-src/apps/api/plane/utils/attachment_preview.py
new file mode 100644
index 0000000..feafa90
--- /dev/null
+++ b/plane-src/apps/api/plane/utils/attachment_preview.py
@@ -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
diff --git a/plane-src/apps/api/plane/utils/upload_limits.py b/plane-src/apps/api/plane/utils/upload_limits.py
new file mode 100644
index 0000000..5f7eb0e
--- /dev/null
+++ b/plane-src/apps/api/plane/utils/upload_limits.py
@@ -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)
diff --git a/plane-src/apps/proxy/Caddyfile.aio.ce b/plane-src/apps/proxy/Caddyfile.aio.ce
index 9cf6d8d..e84f77c 100644
--- a/plane-src/apps/proxy/Caddyfile.aio.ce
+++ b/plane-src/apps/proxy/Caddyfile.aio.ce
@@ -1,6 +1,6 @@
(plane_proxy) {
request_body {
- max_size {$FILE_SIZE_LIMIT}
+ max_size {$PROXY_BODY_SIZE_LIMIT:1073741824}
}
handle /spaces/* {
@@ -43,4 +43,4 @@
{$SITE_ADDRESS} {
import plane_proxy
-}
\ No newline at end of file
+}
diff --git a/plane-src/apps/proxy/Caddyfile.ce b/plane-src/apps/proxy/Caddyfile.ce
index 14559f2..699dd69 100644
--- a/plane-src/apps/proxy/Caddyfile.ce
+++ b/plane-src/apps/proxy/Caddyfile.ce
@@ -1,6 +1,6 @@
(plane_proxy) {
request_body {
- max_size {$FILE_SIZE_LIMIT}
+ max_size {$PROXY_BODY_SIZE_LIMIT:1073741824}
}
redir /spaces /spaces/ permanent
@@ -36,4 +36,4 @@
{$SITE_ADDRESS} {
import plane_proxy
-}
\ No newline at end of file
+}
diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/layout.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/layout.tsx
index e3ea04b..9f5812e 100644
--- a/plane-src/apps/web/app/(all)/[workspaceSlug]/layout.tsx
+++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/layout.tsx
@@ -10,6 +10,7 @@ import { WorkspaceContentWrapper } from "@/plane-web/components/workspace/conten
import { AppRailVisibilityProvider } from "@/plane-web/hooks/app-rail";
import { GlobalModals } from "@/plane-web/components/common/modal/global";
import { WorkspaceAuthWrapper } from "@/layouts/auth-layout/workspace-wrapper";
+import { WorkspaceSettingsModal } from "@/components/workspace/settings/workspace-settings-modal";
import type { Route } from "./+types/layout";
export default function WorkspaceLayout(props: Route.ComponentProps) {
@@ -21,6 +22,7 @@ export default function WorkspaceLayout(props: Route.ComponentProps) {
+
diff --git a/plane-src/apps/web/ce/hooks/use-file-size.ts b/plane-src/apps/web/ce/hooks/use-file-size.ts
index a453d65..c4532fa 100644
--- a/plane-src/apps/web/ce/hooks/use-file-size.ts
+++ b/plane-src/apps/web/ce/hooks/use-file-size.ts
@@ -8,16 +8,30 @@
import { MAX_FILE_SIZE } from "@plane/constants";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
+import { useWorkspace } from "@/hooks/store/use-workspace";
type TReturnProps = {
maxFileSize: number;
+ fileSizeLimitEnabled: boolean;
};
export const useFileSize = (): TReturnProps => {
// store hooks
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 {
- maxFileSize: config?.file_size_limit ?? MAX_FILE_SIZE,
+ maxFileSize,
+ fileSizeLimitEnabled: workspaceLimitEnabled,
};
};
diff --git a/plane-src/apps/web/core/components/issues/attachment/attachment-detail.tsx b/plane-src/apps/web/core/components/issues/attachment/attachment-detail.tsx
index fb486b0..aa48959 100644
--- a/plane-src/apps/web/core/components/issues/attachment/attachment-detail.tsx
+++ b/plane-src/apps/web/core/components/issues/attachment/attachment-detail.tsx
@@ -53,7 +53,7 @@ export const IssueAttachmentsDetail = observer(function IssueAttachmentsDetail(p
// derived values
const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined;
const fileName = getFileName(attachment?.attributes.name ?? "");
- const fileExtension = getFileExtension(attachment?.asset_url ?? "");
+ const fileExtension = getFileExtension(attachment?.attributes.name ?? "");
const fileIcon = getFileIcon(fileExtension, 28);
const fileURL = getFileURL(attachment?.asset_url ?? "");
// hooks
diff --git a/plane-src/apps/web/core/components/issues/attachment/attachment-item-list.tsx b/plane-src/apps/web/core/components/issues/attachment/attachment-item-list.tsx
index e05096b..b9b254f 100644
--- a/plane-src/apps/web/core/components/issues/attachment/attachment-item-list.tsx
+++ b/plane-src/apps/web/core/components/issues/attachment/attachment-item-list.tsx
@@ -51,21 +51,33 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
attachment: { getAttachmentsByIssueId },
attachmentDeleteModalId,
toggleDeleteAttachmentModal,
+ fetchAttachments,
fetchActivities,
} = useIssueDetail(issueServiceType);
const { operations: attachmentOperations, snapshot: attachmentSnapshot } = attachmentHelpers;
const { create: createAttachment } = attachmentOperations;
const { uploadStatus } = attachmentSnapshot;
// file size
- const { maxFileSize } = useFileSize();
+ const { fileSizeLimitEnabled, maxFileSize } = useFileSize();
// derived values
const issueAttachments = getAttachmentsByIssueId(issueId) ?? [];
+ const activeUploads = uploadStatus ?? [];
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
const handleFetchPropertyActivities = useCallback(() => {
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(
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
@@ -73,38 +85,39 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
if (!workspaceSlug) return;
setIsUploading(true);
- Promise.allSettled(acceptedFiles.map((file) => createAttachment(file)))
- .then((results) => {
- const failedUploads = results.filter((result) => result.status === "rejected").length;
- if (failedUploads > 0)
- setToast({
- type: TOAST_TYPE.ERROR,
- title: t("toast.error"),
- message: t("attachment.error"),
- });
- return undefined;
- })
- .finally(() => {
- handleFetchPropertyActivities();
- setIsUploading(false);
- });
+ Promise.allSettled(acceptedFiles.map((file) => createAttachment(file))).finally(() => {
+ handleRefreshAttachments();
+ window.setTimeout(handleRefreshAttachments, 1200);
+ handleFetchPropertyActivities();
+ setIsUploading(false);
+ });
}
if (rejectedFiles.length > 0)
setToast({
type: TOAST_TYPE.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;
},
- [createAttachment, maxFileSize, workspaceSlug, handleFetchPropertyActivities, t]
+ [
+ createAttachment,
+ fileSizeLimitEnabled,
+ maxFileSize,
+ workspaceSlug,
+ handleRefreshAttachments,
+ handleFetchPropertyActivities,
+ t,
+ ]
);
const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({
onDrop,
- maxSize: maxFileSize,
+ maxSize: fileSizeLimitEnabled ? maxFileSize : undefined,
multiple: true,
disabled: isUploading || disabled,
});
@@ -139,7 +152,9 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
{isDragActive ? "Отпустите файлы здесь" : "Перетащите файлы сюда или нажмите для выбора"}
- Любой формат, несколько файлов за раз, до {maxFileSize / 1024 / 1024} МБ на файл
+ {fileSizeLimitEnabled
+ ? `Любой формат, несколько файлов за раз, до ${maxFileSize / 1024 / 1024} МБ на файл`
+ : "Любой формат, несколько файлов за раз, без ограничения размера"}