ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: filestorage upload и preview вложений

This commit is contained in:
DCCONSTRUCTIONS 2026-04-25 13:05:03 +03:00
parent 5f2d543cab
commit c4032e3040
43 changed files with 982 additions and 127 deletions

View File

@ -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 выделяется кругом акцентного цвета, не квадратной плашкой.

View File

@ -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}

View File

@ -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=

View File

@ -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}

View File

@ -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=

View File

@ -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}"

View File

@ -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:

View File

@ -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

View File

@ -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__"

View File

@ -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}"

View File

@ -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:

View File

@ -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),
),
]

View File

@ -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"""

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -1,6 +1,6 @@
(plane_proxy) {
request_body {
max_size {$FILE_SIZE_LIMIT}
max_size {$PROXY_BODY_SIZE_LIMIT:1073741824}
}
handle /spaces/* {

View File

@ -1,6 +1,6 @@
(plane_proxy) {
request_body {
max_size {$FILE_SIZE_LIMIT}
max_size {$PROXY_BODY_SIZE_LIMIT:1073741824}
}
redir /spaces /spaces/ permanent

View File

@ -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>

View File

@ -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,
};
};

View File

@ -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

View File

@ -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) => (

View File

@ -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">
Предпросмотр для этого формата недоступен. Файл можно скачать или открыть в новой вкладке.

View File

@ -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>
);
};

View File

@ -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;

View File

@ -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,
});

View File

@ -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,
}}

View File

@ -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

View File

@ -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>
);
});

View File

@ -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();

View File

@ -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,
},
]}

View File

@ -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;
}
});
}

View File

@ -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;
});
}
}

View File

@ -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];
});
}
};

View File

@ -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;

View File

@ -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:",

View File

@ -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

View File

@ -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}

View File

@ -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

View File

@ -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

View File

@ -14,6 +14,7 @@ export type TIssueAttachment = {
};
asset_url: string;
issue_id: string;
created_at?: string;
// required
updated_at: string;
updated_by: string;

View File

@ -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 {

View File

@ -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