ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: filestorage upload и preview вложений
This commit is contained in:
parent
5f2d543cab
commit
c4032e3040
|
|
@ -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 выделяется кругом акцентного цвета, не квадратной плашкой.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__"
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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"""
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
<AppRailVisibilityProvider>
|
||||
<WorkspaceContentWrapper workspaceSlug={workspaceSlug}>
|
||||
<GlobalModals workspaceSlug={workspaceSlug} />
|
||||
<WorkspaceSettingsModal />
|
||||
<Outlet />
|
||||
</WorkspaceContentWrapper>
|
||||
</AppRailVisibilityProvider>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ? "Отпустите файлы здесь" : "Перетащите файлы сюда или нажмите для выбора"}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-12 text-tertiary">
|
||||
Любой формат, несколько файлов за раз, до {maxFileSize / 1024 / 1024} МБ на файл
|
||||
{fileSizeLimitEnabled
|
||||
? `Любой формат, несколько файлов за раз, до ${maxFileSize / 1024 / 1024} МБ на файл`
|
||||
: "Любой формат, несколько файлов за раз, без ограничения размера"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -148,11 +163,22 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
|
|||
</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 && (
|
||||
<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="text-12 font-semibold uppercase tracking-[0.08em] text-tertiary">Вложения</div>
|
||||
<div className="text-12 text-tertiary">{issueAttachments.length}</div>
|
||||
<div className="text-12 font-semibold tracking-[0.08em] text-tertiary uppercase">Вложения</div>
|
||||
<div className="text-12 text-tertiary">
|
||||
{activeUploads.length > 0 ? `Загрузка ${uploadProgress}%` : issueAttachments.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 overflow-x-auto pb-1">
|
||||
{uploadStatus?.map((status) => (
|
||||
|
|
|
|||
|
|
@ -17,11 +17,12 @@ import { EIssueServiceType } from "@plane/types";
|
|||
import type { TContextMenuItem } from "@plane/ui";
|
||||
import { ActionDropdown, EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
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
|
||||
//
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
import { getFileIcon } from "@/components/icons";
|
||||
import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attachment-pdf-preview";
|
||||
// helpers
|
||||
// hooks
|
||||
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 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 normalizedExtension = extension.toLowerCase();
|
||||
if (IMAGE_EXTENSIONS.has(normalizedExtension)) return "image";
|
||||
|
|
@ -64,7 +72,9 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
const previewType = getPreviewType(fileExtension);
|
||||
const fileIcon = getFileIcon(fileExtension, 32);
|
||||
const fileURL = getFileURL(attachment?.asset_url ?? "");
|
||||
const previewURL = appendSearchParam(fileURL, "preview", "true");
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [isThumbnailError, setIsThumbnailError] = useState(false);
|
||||
const menuItems: TContextMenuItem[] = [
|
||||
{
|
||||
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">
|
||||
{previewType === "image" && fileURL ? (
|
||||
<img src={fileURL} alt={fullFileName} className="h-full w-full object-cover" loading="lazy" />
|
||||
) : previewType === "video" && fileURL ? (
|
||||
{previewType === "image" && previewURL && !isThumbnailError ? (
|
||||
<img
|
||||
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">
|
||||
<Play className="size-6 fill-current" />
|
||||
</div>
|
||||
</>
|
||||
) : previewType === "pdf" && fileURL ? (
|
||||
<div className="flex h-full w-full items-center justify-center bg-white text-red-500">
|
||||
<FileText className="size-9" />
|
||||
</div>
|
||||
) : previewType === "pdf" && previewURL ? (
|
||||
<IssueAttachmentPdfThumbnail fileURL={previewURL} />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
{previewType === "file" ? fileIcon : <ImageIcon className="size-8 text-tertiary" />}
|
||||
|
|
@ -154,10 +168,10 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
isOpen={isPreviewOpen}
|
||||
handleClose={() => setIsPreviewOpen(false)}
|
||||
position={EModalPosition.CENTER}
|
||||
width={EModalWidth.VIXL}
|
||||
className="overflow-hidden border border-subtle bg-surface-1"
|
||||
width={EModalWidth.VIIXL}
|
||||
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">
|
||||
{fileURL && (
|
||||
<a
|
||||
|
|
@ -180,27 +194,26 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pr-24">
|
||||
<div className="flex-shrink-0 pr-24">
|
||||
<div className="text-15 font-semibold text-primary">{fullFileName}</div>
|
||||
<div className="mt-1 text-12 text-tertiary">{convertBytesToSize(attachment.attributes.size)}</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex h-[70vh] max-h-[70vh] items-center justify-center overflow-hidden rounded-2xl bg-black/20">
|
||||
{previewType === "image" && fileURL ? (
|
||||
<img src={fileURL} alt={fullFileName} className="max-h-full max-w-full object-contain" />
|
||||
) : previewType === "video" && fileURL ? (
|
||||
<video src={fileURL} className="max-h-full max-w-full" controls autoPlay>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
) : previewType === "pdf" && fileURL ? (
|
||||
<iframe
|
||||
title={fullFileName}
|
||||
src={fileURL}
|
||||
sandbox="allow-downloads allow-same-origin"
|
||||
className="h-full w-full rounded-2xl bg-white"
|
||||
/>
|
||||
<div className="mt-4 min-h-0 flex-1 overflow-hidden rounded-2xl bg-black/20">
|
||||
{previewType === "image" && previewURL ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<img src={previewURL} alt={fullFileName} className="max-h-full max-w-full object-contain" />
|
||||
</div>
|
||||
) : previewType === "video" && previewURL ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<video src={previewURL} className="max-h-full max-w-full" controls autoPlay>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
</div>
|
||||
) : previewType === "pdf" && previewURL ? (
|
||||
<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="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
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// file size
|
||||
const { maxFileSize } = useFileSize();
|
||||
const { fileSizeLimitEnabled, maxFileSize } = useFileSize();
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
|
|
@ -42,13 +42,13 @@ export const IssueAttachmentUpload = observer(function IssueAttachmentUpload(pro
|
|||
|
||||
const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({
|
||||
onDrop,
|
||||
maxSize: maxFileSize,
|
||||
maxSize: fileSizeLimitEnabled ? maxFileSize : undefined,
|
||||
multiple: false,
|
||||
disabled: isLoading || disabled,
|
||||
});
|
||||
|
||||
const fileError =
|
||||
fileRejections.length > 0
|
||||
fileRejections.length > 0 && fileSizeLimitEnabled
|
||||
? t("attachment_upload.invalid_file_type_or_size", { size: maxFileSize / 1024 / 1024 })
|
||||
: null;
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export const IssueAttachmentActionButton = observer(function IssueAttachmentActi
|
|||
// store hooks
|
||||
const { setLastWidgetAction, fetchActivities } = useIssueDetail(issueServiceType);
|
||||
// file size
|
||||
const { maxFileSize } = useFileSize();
|
||||
const { fileSizeLimitEnabled, maxFileSize } = useFileSize();
|
||||
// operations
|
||||
const { operations: attachmentOperations } = useAttachmentOperations(
|
||||
workspaceSlug,
|
||||
|
|
@ -77,19 +77,21 @@ export const IssueAttachmentActionButton = observer(function IssueAttachmentActi
|
|||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message:
|
||||
totalAttachedFiles > 1
|
||||
? "Only one file can be uploaded at a time."
|
||||
: `File must be of ${maxFileSize / 1024 / 1024}MB or less in size.`,
|
||||
message:
|
||||
totalAttachedFiles > 1
|
||||
? "Only one file can be uploaded at a time."
|
||||
: fileSizeLimitEnabled
|
||||
? `File must be of ${maxFileSize / 1024 / 1024}MB or less in size.`
|
||||
: "File could not be attached. Try uploading again.",
|
||||
});
|
||||
return;
|
||||
},
|
||||
[attachmentOperations, maxFileSize, workspaceSlug, handleFetchPropertyActivities, setLastWidgetAction]
|
||||
[attachmentOperations, fileSizeLimitEnabled, maxFileSize, workspaceSlug, handleFetchPropertyActivities, setLastWidgetAction]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop,
|
||||
maxSize: maxFileSize,
|
||||
maxSize: fileSizeLimitEnabled ? maxFileSize : undefined,
|
||||
multiple: false,
|
||||
disabled: isLoading || disabled,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
"use client";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import { ContextMenu } from "@plane/propel/context-menu";
|
||||
import { CheckIcon } from "@plane/propel/icons";
|
||||
|
|
@ -25,11 +25,17 @@ export const AppRailRoot = observer(() => {
|
|||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
// preferences
|
||||
const { preferences, updateDisplayMode } = useAppRailPreferences();
|
||||
const { isCollapsed, toggleAppRail } = useAppRailVisibility();
|
||||
// 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 railWidth = showLabel ? "3.75rem" : "3rem";
|
||||
|
||||
|
|
@ -57,7 +63,7 @@ export const AppRailRoot = observer(() => {
|
|||
item={{
|
||||
label: "Settings",
|
||||
icon: <SettingsIcon className="size-5" />,
|
||||
href: `/${workspaceSlug}/settings`,
|
||||
href: workspaceSettingsHref,
|
||||
isActive: isWorkspaceSettingsPath,
|
||||
showLabel,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { useEffect, useState } from "react";
|
|||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// 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 { Button } from "@plane/propel/button";
|
||||
import { EditIcon } from "@plane/propel/icons";
|
||||
|
|
@ -32,6 +32,21 @@ const defaultValues: Partial<IWorkspace> = {
|
|||
organization_size: "2-10",
|
||||
logo_url: null,
|
||||
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() {
|
||||
|
|
@ -55,6 +70,7 @@ export const WorkspaceDetails = observer(function WorkspaceDetails() {
|
|||
});
|
||||
// derived values
|
||||
const workspaceLogo = watch("logo_url");
|
||||
const storageFileSizeLimitEnabled = watch("storage_file_size_limit_enabled") ?? true;
|
||||
|
||||
const onSubmit = async (formData: IWorkspace) => {
|
||||
if (!currentWorkspace) return;
|
||||
|
|
@ -65,6 +81,8 @@ export const WorkspaceDetails = observer(function WorkspaceDetails() {
|
|||
name: formData.name,
|
||||
organization_size: formData.organization_size,
|
||||
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 {
|
||||
|
|
@ -279,6 +297,96 @@ export const WorkspaceDetails = observer(function WorkspaceDetails() {
|
|||
</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 && (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<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 Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||
import { Settings, UserPlus } from "lucide-react";
|
||||
import { Menu } from "@headlessui/react";
|
||||
// plane imports
|
||||
|
|
@ -29,8 +29,13 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
|
|||
const { workspace, activeWorkspace, handleItemClick, handleWorkspaceNavigation, handleClose } = props;
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
// hooks
|
||||
const { t } = useTranslation();
|
||||
const settingsModalSearchParams = new URLSearchParams(searchParams?.toString());
|
||||
settingsModalSearchParams.set("workspaceSettings", "general");
|
||||
const workspaceSettingsHref = `${pathname || `/${workspace.slug}`}?${settingsModalSearchParams.toString()}`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
|
@ -93,7 +98,7 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
|
|||
<div className="mt-2 mb-1 grid grid-cols-2 gap-3">
|
||||
{[EUserPermissions.ADMIN, EUserPermissions.MEMBER].includes(workspace?.role) && (
|
||||
<Link
|
||||
href={`/${workspace.slug}/settings`}
|
||||
href={workspaceSettingsHref}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClose();
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import { useState } from "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 { Disclosure } from "@headlessui/react";
|
||||
// plane imports
|
||||
|
|
@ -32,12 +32,17 @@ export const SidebarWorkspaceMenuHeader = observer(function SidebarWorkspaceMenu
|
|||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
// hooks
|
||||
const { workspaceSlug } = useParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { t } = useTranslation();
|
||||
// TODO: fix types
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
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 (
|
||||
<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",
|
||||
title: t("settings"),
|
||||
icon: Settings,
|
||||
action: () => router.push(`/${workspaceSlug}/settings`),
|
||||
action: () => router.push(workspaceSettingsHref),
|
||||
shouldRender: isAdmin,
|
||||
},
|
||||
]}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export class FileUploadService extends APIService {
|
|||
if (axios.isCancel(error)) {
|
||||
console.log(error.message);
|
||||
} else {
|
||||
throw error?.response?.data;
|
||||
throw error?.response?.data ?? error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,16 +55,32 @@ export class IssueAttachmentService extends APIService {
|
|||
.then(async (response) => {
|
||||
const signedURLResponse: TIssueAttachmentUploadResponse = response?.data;
|
||||
const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file);
|
||||
await this.fileUploadService.uploadFile(
|
||||
signedURLResponse.upload_data.url,
|
||||
fileUploadPayload,
|
||||
uploadProgressHandler
|
||||
);
|
||||
await this.updateIssueAttachmentUploadStatus(workspaceSlug, projectId, issueId, signedURLResponse.asset_id);
|
||||
let uploadError: unknown;
|
||||
try {
|
||||
await this.fileUploadService.uploadFile(
|
||||
signedURLResponse.upload_data.url,
|
||||
fileUploadPayload,
|
||||
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;
|
||||
})
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
throw error?.response?.data ?? error;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -74,7 +90,7 @@ export class IssueAttachmentService extends APIService {
|
|||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
throw error?.response?.data ?? error;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -89,7 +105,7 @@ export class IssueAttachmentService extends APIService {
|
|||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
throw error?.response?.data ?? error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* 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 { computedFn } from "mobx-utils";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
|
@ -23,6 +23,17 @@ export type TAttachmentUploadStatus = {
|
|||
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 {
|
||||
// actions
|
||||
addAttachments: (issueId: string, attachments: TIssueAttachment[]) => void;
|
||||
|
|
@ -119,17 +130,31 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
|
|||
// actions
|
||||
addAttachments = (issueId: string, attachments: TIssueAttachment[]) => {
|
||||
if (attachments && attachments.length > 0) {
|
||||
const newAttachmentIds = attachments.map((attachment) => attachment.id);
|
||||
runInAction(() => {
|
||||
update(this.attachments, [issueId], (attachmentIds = []) => uniq(concat(attachmentIds, newAttachmentIds)));
|
||||
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) => {
|
||||
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;
|
||||
};
|
||||
|
||||
|
|
@ -164,13 +189,23 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
|
|||
);
|
||||
|
||||
if (response && response.id) {
|
||||
runInAction(() => {
|
||||
update(this.attachments, [issueId], (attachmentIds = []) => uniq(concat(attachmentIds, [response.id])));
|
||||
set(this.attachmentMap, response.id, response);
|
||||
this.rootIssueStore.issues.updateIssue(issueId, {
|
||||
attachment_count: this.getAttachmentsCountByIssueId(issueId),
|
||||
try {
|
||||
runInAction(() => {
|
||||
set(this.attachmentsUploadStatusMap, [issueId, tempId, "progress"], 100);
|
||||
});
|
||||
});
|
||||
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;
|
||||
|
|
@ -179,7 +214,7 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
|
|||
throw error;
|
||||
} finally {
|
||||
runInAction(() => {
|
||||
delete this.attachmentsUploadStatusMap[issueId][tempId];
|
||||
if (this.attachmentsUploadStatusMap[issueId]) delete this.attachmentsUploadStatusMap[issueId][tempId];
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -38,6 +38,14 @@ http {
|
|||
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)$ {
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@
|
|||
"mobx-react": "catalog:",
|
||||
"mobx-utils": "catalog:",
|
||||
"next-themes": "0.4.6",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"react": "catalog:",
|
||||
"react-color": "^2.19.3",
|
||||
"react-dom": "catalog:",
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ AWS_S3_ENDPOINT_URL=https://s3.amazonaws.com
|
|||
AWS_S3_BUCKET_NAME=
|
||||
BUCKET_NAME=
|
||||
FILE_SIZE_LIMIT=5242880
|
||||
PROXY_BODY_SIZE_LIMIT=1073741824
|
||||
|
||||
# Gunicorn Workers
|
||||
GUNICORN_WORKERS=1
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
# Gunicorn Workers
|
||||
GUNICORN_WORKERS=1
|
||||
|
|
|
|||
|
|
@ -162,7 +162,8 @@ services:
|
|||
- ${LISTEN_HTTP_PORT}:80
|
||||
- ${LISTEN_HTTPS_PORT}:443
|
||||
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}
|
||||
depends_on:
|
||||
- web
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export type TIssueAttachment = {
|
|||
};
|
||||
asset_url: string;
|
||||
issue_id: string;
|
||||
created_at?: string;
|
||||
// required
|
||||
updated_at: string;
|
||||
updated_by: string;
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ export interface IWorkspace {
|
|||
total_projects?: number;
|
||||
role: number;
|
||||
timezone: string;
|
||||
storage_file_size_limit_enabled: boolean;
|
||||
storage_file_size_limit: number;
|
||||
}
|
||||
|
||||
export interface IWorkspaceLite {
|
||||
|
|
|
|||
|
|
@ -662,6 +662,9 @@ importers:
|
|||
next-themes:
|
||||
specifier: 0.4.6
|
||||
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:
|
||||
specifier: 'catalog:'
|
||||
version: 18.3.1
|
||||
|
|
|
|||
Loading…
Reference in New Issue