From 5f2d543cab0a1ac55aa6da49921eb369ce65f27d Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 25 Apr 2026 10:34:37 +0300 Subject: [PATCH] =?UTF-8?q?UI=20-=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E?= =?UTF-8?q?=D0=95=D0=9A=D0=A2=D0=9D=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C?= =?UTF-8?q?=D0=A3=D0=9D=D0=98=D0=9A=D0=90=D0=A6=D0=98=D0=AF:=20=D0=B7?= =?UTF-8?q?=D0=BE=D0=BD=D0=B0=20=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9=20=D0=B2=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D0=B5=20=D0=B4=D0=B5=D1=82=D0=B0=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attachment/attachment-item-list.tsx | 135 ++++++------ .../attachment/attachment-list-item.tsx | 192 +++++++++++++----- .../attachment-list-upload-item.tsx | 19 +- .../issue-detail-widget-collapsibles.tsx | 4 +- .../issues/issue-detail-widgets/root.tsx | 17 +- 5 files changed, 248 insertions(+), 119 deletions(-) diff --git a/plane-src/apps/web/core/components/issues/attachment/attachment-item-list.tsx b/plane-src/apps/web/core/components/issues/attachment/attachment-item-list.tsx index 0ac4db8..e05096b 100644 --- a/plane-src/apps/web/core/components/issues/attachment/attachment-item-list.tsx +++ b/plane-src/apps/web/core/components/issues/attachment/attachment-item-list.tsx @@ -59,7 +59,8 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList // file size const { maxFileSize } = useFileSize(); // derived values - const issueAttachments = getAttachmentsByIssueId(issueId); + const issueAttachments = getAttachmentsByIssueId(issueId) ?? []; + const hasAttachmentRows = issueAttachments.length > 0 || !!uploadStatus?.length; // handlers const handleFetchPropertyActivities = useCallback(() => { @@ -68,80 +69,96 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList const onDrop = useCallback( (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { - const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length; - - if (rejectedFiles.length === 0) { - const currentFile: File = acceptedFiles[0]; - if (!currentFile || !workspaceSlug) return; - + if (acceptedFiles.length > 0) { + if (!workspaceSlug) return; setIsUploading(true); - createAttachment(currentFile) - .catch(() => { - setToast({ - type: TOAST_TYPE.ERROR, - title: t("toast.error"), - message: t("attachment.error"), - }); + + 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); }); - return; } - setToast({ - type: TOAST_TYPE.ERROR, - title: t("toast.error"), - message: - totalAttachedFiles > 1 - ? t("attachment.only_one_file_allowed") - : t("attachment.file_size_limit", { size: maxFileSize / 1024 / 1024 }), - }); + if (rejectedFiles.length > 0) + setToast({ + type: TOAST_TYPE.ERROR, + title: t("toast.error"), + message: t("attachment.file_size_limit", { size: maxFileSize / 1024 / 1024 }), + }); + return; }, - [createAttachment, maxFileSize, workspaceSlug, handleFetchPropertyActivities] + [createAttachment, maxFileSize, workspaceSlug, handleFetchPropertyActivities, t] ); - const { getRootProps, getInputProps, isDragActive } = useDropzone({ + const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({ onDrop, maxSize: maxFileSize, - multiple: false, + multiple: true, disabled: isUploading || disabled, }); return ( - <> - {uploadStatus?.map((uploadStatus) => ( - - ))} - {issueAttachments && ( - <> - {attachmentDeleteModalId && ( - toggleDeleteAttachmentModal(null)} - attachmentOperations={attachmentOperations} - attachmentId={attachmentDeleteModalId} - issueServiceType={issueServiceType} - /> - )} -
- - {isDragActive && ( -
-
-
- - {t("attachment.drag_and_drop")} -
-
-
- )} - {issueAttachments?.map((attachmentId) => ( +
+ {attachmentDeleteModalId && ( + toggleDeleteAttachmentModal(null)} + attachmentOperations={attachmentOperations} + attachmentId={attachmentDeleteModalId} + issueServiceType={issueServiceType} + /> + )} + +
+ +
+
+ +
+
+
+ {isDragActive ? "Отпустите файлы здесь" : "Перетащите файлы сюда или нажмите для выбора"} +
+
+ Любой формат, несколько файлов за раз, до {maxFileSize / 1024 / 1024} МБ на файл +
+
+
+
+ Выбрать +
+
+ + {hasAttachmentRows && ( +
+
+
Вложения
+
{issueAttachments.length}
+
+
+ {uploadStatus?.map((status) => ( + + ))} + {issueAttachments.map((attachmentId) => ( ))}
- +
)} - +
); }); diff --git a/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx b/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx index ca396aa..2b467fe 100644 --- a/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx +++ b/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx @@ -5,6 +5,7 @@ */ import { observer } from "mobx-react"; +import { useState } from "react"; import { useTranslation } from "@plane/i18n"; import { getIconButtonStyling } from "@plane/propel/icon-button"; @@ -14,8 +15,9 @@ import type { TIssueServiceType } from "@plane/types"; import { EIssueServiceType } from "@plane/types"; // ui import type { TContextMenuItem } from "@plane/ui"; -import { ActionDropdown } 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"; // components // import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; @@ -32,6 +34,18 @@ type TIssueAttachmentsListItem = { issueServiceType?: TIssueServiceType; }; +const IMAGE_EXTENSIONS = new Set(["apng", "avif", "bmp", "gif", "jpg", "jpeg", "png", "svg", "webp"]); +const VIDEO_EXTENSIONS = new Set(["avi", "m4v", "mov", "mp4", "mpeg", "mpg", "ogv", "webm"]); +const PDF_EXTENSIONS = new Set(["pdf"]); + +const getPreviewType = (extension: string) => { + const normalizedExtension = extension.toLowerCase(); + if (IMAGE_EXTENSIONS.has(normalizedExtension)) return "image"; + if (VIDEO_EXTENSIONS.has(normalizedExtension)) return "video"; + if (PDF_EXTENSIONS.has(normalizedExtension)) return "pdf"; + return "file"; +}; + export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) { const { t } = useTranslation(); // props @@ -46,8 +60,11 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined; const fileName = getFileName(attachment?.attributes.name ?? ""); const fileExtension = getFileExtension(attachment?.attributes.name ?? ""); - const fileIcon = getFileIcon(fileExtension, 18); + const fullFileName = fileExtension ? `${fileName}.${fileExtension}` : fileName; + const previewType = getPreviewType(fileExtension); + const fileIcon = getFileIcon(fileExtension, 32); const fileURL = getFileURL(attachment?.asset_url ?? ""); + const [isPreviewOpen, setIsPreviewOpen] = useState(false); const menuItems: TContextMenuItem[] = [ { key: "delete", @@ -64,56 +81,135 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt if (!attachment) return <>; return ( -
{ - e.preventDefault(); - e.stopPropagation(); - window.open(fileURL, "_blank"); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - e.stopPropagation(); - window.open(fileURL, "_blank"); - } - }} - > -
-
-
{fileIcon}
- -

{`${fileName}.${fileExtension}`}

-
- - {convertBytesToSize(attachment.attributes.size)} -
- -
- {attachment?.created_by && ( + <> +
+ + +
+ +
-
+ + setIsPreviewOpen(false)} + position={EModalPosition.CENTER} + width={EModalWidth.VIXL} + className="overflow-hidden border border-subtle bg-surface-1" + > +
+
+ {fileURL && ( + e.stopPropagation()} + > + + + )} + +
+ +
+
{fullFileName}
+
{convertBytesToSize(attachment.attributes.size)}
+
+ +
+ {previewType === "image" && fileURL ? ( + {fullFileName} + ) : previewType === "video" && fileURL ? ( + + ) : previewType === "pdf" && fileURL ? ( +