From f9308539bfe71e4e2359c47ed5c230fa1fb386c9 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 29 Aug 2026 16:08:02 +0300 Subject: [PATCH] FEAT - OPS ATTACHMENTS: multi-format upload, stable card drop zone and text previews --- plane-src/apps/api/plane/settings/common.py | 7 + .../attachment/attachment-list-item.tsx | 143 ++++++++++++-- .../components/issues/peek-overview/view.tsx | 177 +++++++++++++++++- plane-src/apps/web/styles/globals.css | 75 ++++++++ .../packages/services/src/file/helper.ts | 43 ++++- 5 files changed, 423 insertions(+), 22 deletions(-) diff --git a/plane-src/apps/api/plane/settings/common.py b/plane-src/apps/api/plane/settings/common.py index fd53132..352a31c 100644 --- a/plane-src/apps/api/plane/settings/common.py +++ b/plane-src/apps/api/plane/settings/common.py @@ -461,8 +461,14 @@ ATTACHMENT_MIME_TYPES = [ "text/css", "text/javascript", "application/json", + "application/x-ndjson", + "application/yaml", + "application/x-yaml", + "text/yaml", + "application/toml", "text/xml", "text/csv", + "text/tab-separated-values", "application/xml", # SQL "application/x-sql", @@ -470,6 +476,7 @@ ATTACHMENT_MIME_TYPES = [ "application/x-gzip", # Markdown "text/markdown", + "text/x-markdown", ] # Seed directory path 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 3c43a20..859e250 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 @@ -19,11 +19,12 @@ import { EIssueServiceType } from "@plane/types"; import type { TContextMenuItem } from "@plane/ui"; import { ActionDropdown, EModalPosition, EModalWidth, ModalCore, Spinner } from "@plane/ui"; import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils"; -import { AlertTriangle, Box, Download, Eye, History, ImageIcon, Play, UploadCloud, X } from "lucide-react"; +import { AlertTriangle, Box, Download, Eye, FileText, History, ImageIcon, Play, UploadCloud, X } from "lucide-react"; // components // import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; import { getFileIcon } from "@/components/icons"; +import { MarkdownRenderer } from "@/components/ui/markdown-to-component"; import { buildBeamViewerUrl, dispatchBeamViewerOpenEvent, @@ -57,6 +58,26 @@ type TIssueAttachmentsListItem = { 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 MARKDOWN_EXTENSIONS = new Set(["markdown", "md", "mdown", "mkd", "mkdn"]); +const TEXT_EXTENSIONS = new Set([ + "cfg", + "conf", + "csv", + "ini", + "json", + "jsonl", + "log", + "ndjson", + "toml", + "tsv", + "txt", + "xml", + "yaml", + "yml", +]); +const MAX_INLINE_TEXT_PREVIEW_SIZE = 8 * 1024 * 1024; + +type TAttachmentPreviewType = "image" | "video" | "pdf" | "markdown" | "text" | "file"; const appendSearchParam = (url: string | undefined, key: string, value: string): string => { if (!url) return ""; @@ -94,11 +115,13 @@ const withBeamViewerSettingsSrc = ( } }; -const getPreviewType = (extension: string) => { +const getPreviewType = (extension: string): TAttachmentPreviewType => { 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"; + if (MARKDOWN_EXTENSIONS.has(normalizedExtension)) return "markdown"; + if (TEXT_EXTENSIONS.has(normalizedExtension)) return "text"; return "file"; }; @@ -170,8 +193,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt }) : undefined) || storedModelViewerUrlWithSettings; const canOpenModelViewer = - !!beamViewer && - (beamViewer.previewAvailable || beamEffectiveStatus === "ready" || !!modelViewerUrl); + !!beamViewer && (beamViewer.previewAvailable || beamEffectiveStatus === "ready" || !!modelViewerUrl); const modelDownloadUrl = beamViewer?.downloadUrl || beamViewer?.src; const previewDownloadUrl = modelDownloadUrl || fileURL; const isBeamModel = isBeamModelFile(fullFileName); @@ -210,7 +232,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt () => mergeBeamModelVersionRecordLists(localBeamVersions, liveBeamVersions), [localBeamVersions, liveBeamVersions] ); - const rawVersion = beamViewer?.version ?? (attachment?.attributes as { version?: number | string } | undefined)?.version; + const rawVersion = + beamViewer?.version ?? (attachment?.attributes as { version?: number | string } | undefined)?.version; const versionLabel = typeof rawVersion === "number" ? `v${rawVersion}` @@ -476,15 +499,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt isMounted = false; if (timeoutId) window.clearTimeout(timeoutId); }; - }, [ - attachmentId, - attachmentService, - beamViewer, - issueId, - projectId, - storedModelViewerUrl, - workspaceSlug, - ]); + }, [attachmentId, attachmentService, beamViewer, issueId, projectId, storedModelViewerUrl, workspaceSlug]); if (!attachment) return <>; @@ -523,6 +538,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt ) : ( ) + ) : previewType === "markdown" || previewType === "text" ? ( + ) : previewType === "file" ? ( getFileIcon(fileExtension, 18) ) : ( @@ -720,6 +737,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt ) : ( ) + ) : previewType === "markdown" || previewType === "text" ? ( + ) : previewType === "file" ? ( fileIcon ) : ( @@ -812,7 +831,7 @@ type TAttachmentPreviewContent = { isBeamConversionFailed: boolean; modelDownloadUrl: string | undefined; previewDownloadUrl: string | undefined; - previewType: "image" | "video" | "pdf" | "file"; + previewType: TAttachmentPreviewType; previewURL: string; setIsPreviewOpen: (isOpen: boolean) => void; size: number; @@ -876,6 +895,13 @@ const AttachmentPreviewContent = (props: TAttachmentPreviewContent) => { ) : previewType === "pdf" && previewURL ? ( + ) : (previewType === "markdown" || previewType === "text") && previewURL ? ( + ) : isBeamAwaitingPreview || isBeamConversionFailed ? (
{ ); }; +type TAttachmentTextPreview = { + fileName: string; + fileURL: string; + isMarkdown: boolean; + size: number; +}; + +const AttachmentTextPreview = (props: TAttachmentTextPreview) => { + const { fileName, fileURL, isMarkdown, size } = props; + const [content, setContent] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (size > MAX_INLINE_TEXT_PREVIEW_SIZE) { + setContent(null); + setError(`Файл больше ${convertBytesToSize(MAX_INLINE_TEXT_PREVIEW_SIZE)}. Скачайте его для просмотра.`); + return; + } + + const controller = new AbortController(); + setContent(null); + setError(null); + + fetch(fileURL, { + cache: "no-store", + credentials: "include", + signal: controller.signal, + }) + .then(async (response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.text(); + }) + .then((text) => setContent(text)) + .catch((fetchError) => { + if (controller.signal.aborted) return; + console.error("Error in loading text attachment preview:", fetchError); + setError("Не удалось загрузить содержимое файла для предпросмотра."); + }); + + return () => controller.abort(); + }, [fileURL, size]); + + const renderedContent = useMemo(() => { + if (content === null || isMarkdown) return content; + const extension = getFileExtension(fileName).toLowerCase(); + if (extension !== "json") return content; + + try { + return JSON.stringify(JSON.parse(content), null, 2); + } catch (_error) { + return content; + } + }, [content, fileName, isMarkdown]); + + if (error) { + return ( +
{error}
+ ); + } + + if (renderedContent === null) { + return ( +
+ +
+ ); + } + + return ( +
+ {isMarkdown ? ( +
+ +
+ ) : ( +
+          {renderedContent}
+        
+ )} +
+ ); +}; + type TBeamVersionHistoryModal = { currentVersionId: string | undefined; deletingVersionKey: string | null; diff --git a/plane-src/apps/web/core/components/issues/peek-overview/view.tsx b/plane-src/apps/web/core/components/issues/peek-overview/view.tsx index a9f6b75..3554891 100644 --- a/plane-src/apps/web/core/components/issues/peek-overview/view.tsx +++ b/plane-src/apps/web/core/components/issues/peek-overview/view.tsx @@ -10,14 +10,16 @@ import { useRef, useState, type CSSProperties, + type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type ReactNode, } from "react"; import { observer } from "mobx-react"; import { createPortal } from "react-dom"; -import { Download, Maximize2, Minimize2, X } from "lucide-react"; +import { Download, Maximize2, Minimize2, UploadCloud, X } from "lucide-react"; // plane imports import type { EditorRefApi } from "@plane/editor"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { TNameDescriptionLoader } from "@plane/types"; import { EIssueServiceType } from "@plane/types"; import { cn, convertBytesToSize } from "@plane/utils"; @@ -26,6 +28,7 @@ import { BEAM_VIEWER_OPEN_EVENT, type TBeamViewerOpenEventDetail } from "@/helpe import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import useKeypress from "@/hooks/use-keypress"; import usePeekOverviewOutsideClickDetector from "@/hooks/use-peek-overview-outside-click"; +import { useFileSize } from "@/plane-web/hooks/use-file-size"; // local imports import type { TIssueOperations } from "../issue-detail"; import { IssueActivity } from "../issue-detail/issue-activity"; @@ -40,6 +43,8 @@ import { PeekOverviewProperties } from "./properties"; const SIDE_PEEK_WIDTH_STORAGE_KEY = "nodedc:issue-peek-width"; const BEAM_VIEWER_CLOSING_CLASS_NAME = "nodedc-beam-viewer-closing"; +const isFileDragEvent = (event: ReactDragEvent) => Array.from(event.dataTransfer.types).includes("Files"); + interface IIssueView { workspaceSlug: string; projectId: string; @@ -96,6 +101,8 @@ export const IssueView = observer(function IssueView(props: IIssueView) { const [beamPeekViewer, setBeamPeekViewer] = useState(null); const [isBeamPeekFullscreen, setIsBeamPeekFullscreen] = useState(false); const [isBeamPeekClosing, setIsBeamPeekClosing] = useState(false); + const [isCardAttachmentUploading, setIsCardAttachmentUploading] = useState(false); + const [isCardAttachmentDragActive, setIsCardAttachmentDragActive] = useState(false); const [sidePeekWidth, setSidePeekWidth] = useState(() => { if (typeof window === "undefined") return 720; @@ -114,17 +121,158 @@ export const IssueView = observer(function IssueView(props: IIssueView) { const initialMouseXRef = useRef(0); const livePeekWidthRef = useRef(sidePeekWidth); const beamCloseTimeoutRef = useRef(null); + const cardAttachmentDragDepthRef = useRef(0); // store hooks const { setPeekIssue, isAnyModalOpen, + createAttachment, + fetchActivities, + fetchAttachments, issue: { getIssueById }, } = useIssueDetail(); const { isAnyModalOpen: isAnyEpicModalOpen } = useIssueDetail(EIssueServiceType.EPICS); + const { fileSizeLimitEnabled, maxFileSize } = useFileSize(); const issue = getIssueById(issueId); const shouldUseInteractiveEmbeddedLayout = embedIssue && interactiveEmbeddedLayout; const shouldRenderPeekSurface = !embedIssue || shouldUseInteractiveEmbeddedLayout; const shouldAllowPeekResize = !embedIssue || shouldUseInteractiveEmbeddedLayout; + const isCardAttachmentDropDisabled = + disabled || is_archived || !!isLoading || !!isError || !issue || isCardAttachmentUploading; + + const handleCardAttachmentDrop = useCallback( + async (acceptedFiles: File[], rejectedFileCount = 0) => { + if (acceptedFiles.length === 0) { + if (rejectedFileCount > 0) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Файлы не прикреплены", + message: fileSizeLimitEnabled + ? `Проверьте размер файлов: максимум ${Math.round(maxFileSize / 1024 / 1024)} МБ на файл.` + : "Не удалось принять выбранные файлы.", + }); + } + return; + } + + setIsCardAttachmentUploading(true); + try { + const uploadResults = await Promise.allSettled( + acceptedFiles.map((file) => createAttachment(workspaceSlug, projectId, issueId, file)) + ); + const failedUploads = uploadResults.filter((result) => result.status === "rejected"); + + await Promise.allSettled([ + fetchAttachments(workspaceSlug, projectId, issueId), + fetchActivities(workspaceSlug, projectId, issueId), + ]); + + if (failedUploads.length > 0 || rejectedFileCount > 0) { + const failedCount = failedUploads.length + rejectedFileCount; + setToast({ + type: TOAST_TYPE.ERROR, + title: + failedCount === acceptedFiles.length + rejectedFileCount ? "Файлы не прикреплены" : "Загружено не всё", + message: `${failedCount} ${failedCount === 1 ? "файл не удалось прикрепить" : "файла не удалось прикрепить"}.`, + }); + } else { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: acceptedFiles.length === 1 ? "Файл прикреплён" : "Файлы прикреплены", + message: + acceptedFiles.length === 1 + ? (acceptedFiles[0]?.name ?? "Вложение добавлено в карточку.") + : `${acceptedFiles.length} файлов добавлено в карточку.`, + }); + } + } finally { + setIsCardAttachmentUploading(false); + } + }, + [ + createAttachment, + fetchActivities, + fetchAttachments, + fileSizeLimitEnabled, + issueId, + maxFileSize, + projectId, + workspaceSlug, + ] + ); + + const resetCardAttachmentDrag = useCallback(() => { + cardAttachmentDragDepthRef.current = 0; + setIsCardAttachmentDragActive(false); + }, []); + + useEffect(() => { + window.addEventListener("dragend", resetCardAttachmentDrag, true); + window.addEventListener("drop", resetCardAttachmentDrag, true); + window.addEventListener("blur", resetCardAttachmentDrag); + + return () => { + window.removeEventListener("dragend", resetCardAttachmentDrag, true); + window.removeEventListener("drop", resetCardAttachmentDrag, true); + window.removeEventListener("blur", resetCardAttachmentDrag); + }; + }, [resetCardAttachmentDrag]); + + const handleCardDragEnterCapture = useCallback( + (event: ReactDragEvent) => { + if (isCardAttachmentDropDisabled || !isFileDragEvent(event)) return; + + event.preventDefault(); + event.stopPropagation(); + cardAttachmentDragDepthRef.current += 1; + setIsCardAttachmentDragActive(true); + }, + [isCardAttachmentDropDisabled] + ); + + const handleCardDragOverCapture = useCallback( + (event: ReactDragEvent) => { + if (isCardAttachmentDropDisabled || !isFileDragEvent(event)) return; + + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "copy"; + setIsCardAttachmentDragActive(true); + }, + [isCardAttachmentDropDisabled] + ); + + const handleCardDragLeaveCapture = useCallback( + (event: ReactDragEvent) => { + if (isCardAttachmentDropDisabled || !isFileDragEvent(event)) return; + + event.preventDefault(); + event.stopPropagation(); + cardAttachmentDragDepthRef.current = Math.max(0, cardAttachmentDragDepthRef.current - 1); + if (cardAttachmentDragDepthRef.current === 0) setIsCardAttachmentDragActive(false); + }, + [isCardAttachmentDropDisabled] + ); + + const handleCardDropCapture = useCallback( + (event: ReactDragEvent) => { + if (!isFileDragEvent(event)) return; + + event.preventDefault(); + event.stopPropagation(); + resetCardAttachmentDrag(); + if (isCardAttachmentDropDisabled) return; + + const droppedFiles = Array.from(event.dataTransfer.files); + const acceptedFiles = fileSizeLimitEnabled + ? droppedFiles.filter((file) => file.size <= maxFileSize) + : droppedFiles; + const rejectedFileCount = droppedFiles.length - acceptedFiles.length; + + void handleCardAttachmentDrop(acceptedFiles, rejectedFileCount); + }, + [fileSizeLimitEnabled, handleCardAttachmentDrop, isCardAttachmentDropDisabled, maxFileSize, resetCardAttachmentDrag] + ); // remove peek id const removeRoutePeekId = () => { setPeekIssue(undefined); @@ -407,7 +555,32 @@ export const IssueView = observer(function IssueView(props: IIssueView) { }; const issuePanel = issueId ? ( -
+
+ {isCardAttachmentDragActive && ( +
+
+
+
+ +
+
Отпустите файлы, чтобы прикрепить
+
+ {fileSizeLimitEnabled + ? `Можно несколько файлов за раз, до ${Math.round(maxFileSize / 1024 / 1024)} МБ каждый` + : "Можно прикрепить несколько файлов за раз"} +
+
+
+ )} {shouldAllowPeekResize && peekMode === "side-peek" && (
* + * { + margin-top: 1rem; + } + + .nodedc-attachment-markdown-preview h1, + .nodedc-attachment-markdown-preview h2, + .nodedc-attachment-markdown-preview h3, + .nodedc-attachment-markdown-preview h4 { + color: var(--text-color-primary); + line-height: 1.3; + } + + .nodedc-attachment-markdown-preview h1 { + font-size: 1.5rem; + } + + .nodedc-attachment-markdown-preview h2 { + font-size: 1.25rem; + } + + .nodedc-attachment-markdown-preview h3, + .nodedc-attachment-markdown-preview h4 { + font-size: 1rem; + } + + .nodedc-attachment-markdown-preview blockquote { + padding-left: 1rem; + border-left: 2px solid rgba(var(--nodedc-accent-rgb), 0.48); + color: var(--text-color-tertiary); + } + + .nodedc-attachment-markdown-preview code { + padding: 0.12rem 0.35rem; + border-radius: 0.35rem; + background: rgba(255, 255, 255, 0.07); + color: var(--text-color-primary); + font-family: var(--font-code); + } + + .nodedc-attachment-markdown-preview pre { + overflow-x: auto; + padding: 1rem; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 0.85rem; + background: rgba(0, 0, 0, 0.24); + } + + .nodedc-attachment-markdown-preview pre code { + padding: 0; + background: transparent; + } + + .nodedc-attachment-markdown-preview table { + display: block; + overflow-x: auto; + width: 100%; + border-collapse: collapse; + } + + .nodedc-attachment-markdown-preview th, + .nodedc-attachment-markdown-preview td { + padding: 0.55rem 0.75rem; + border: 1px solid rgba(255, 255, 255, 0.08); + text-align: left; + } + + .nodedc-attachment-markdown-preview a { + color: rgb(var(--nodedc-accent-rgb)); + } + .nodedc-attachments-panel[data-view-mode="list"] { padding-bottom: 0.75rem; } diff --git a/plane-src/packages/services/src/file/helper.ts b/plane-src/packages/services/src/file/helper.ts index b8e9628..b3de2b4 100644 --- a/plane-src/packages/services/src/file/helper.ts +++ b/plane-src/packages/services/src/file/helper.ts @@ -10,6 +10,30 @@ import { fileTypeFromBuffer } from "file-type"; import type { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types"; import { DANGEROUS_EXTENSIONS } from "@plane/constants"; +const TEXT_MIME_TYPES_BY_EXTENSION: Readonly> = { + cfg: "text/plain", + conf: "text/plain", + csv: "text/csv", + ini: "text/plain", + json: "application/json", + jsonl: "application/x-ndjson", + log: "text/plain", + markdown: "text/markdown", + md: "text/markdown", + mdown: "text/markdown", + mkd: "text/markdown", + mkdn: "text/markdown", + ndjson: "application/x-ndjson", + toml: "application/toml", + tsv: "text/tab-separated-values", + txt: "text/plain", + xml: "application/xml", + yaml: "application/yaml", + yml: "application/yaml", +}; + +const getFileExtension = (filename: string): string => filename.split(".").pop()?.trim().toLowerCase() ?? ""; + /** * @description Filename validation - checks for double extensions and dangerous patterns * @param {string} filename @@ -82,8 +106,10 @@ const detectMimeTypeFromSignature = async (file: File): Promise => { }; /** - * @description Validate and detect the MIME type of a file using signature detection - * Also performs basic security checks on filename + * @description Validate and detect the MIME type of a file. + * Binary signatures take precedence. Text formats are resolved from a conservative + * extension map because they do not have a binary signature. Browser MIME metadata + * and application/octet-stream provide compatibility for other safe attachments. * @param {File} file * @returns {Promise} validated and detected MIME type */ @@ -91,7 +117,7 @@ const validateAndDetectFileType = async (file: File): Promise => { // Basic filename validation const filenameError = validateFilename(file.name); if (filenameError) { - console.warn(`File validation warning: ${filenameError}`); + throw new Error(filenameError); } try { @@ -103,8 +129,15 @@ const validateAndDetectFileType = async (file: File): Promise => { console.warn("Error detecting file type from signature:", _error); } - // fallback for unknown files - return ""; + const extensionMimeType = TEXT_MIME_TYPES_BY_EXTENSION[getFileExtension(file.name)]; + if (extensionMimeType) return extensionMimeType; + + const browserMimeType = file.type.split(";", 1)[0]?.trim().toLowerCase(); + if (browserMimeType) return browserMimeType; + + // Preserve generic attachment support when neither the file signature nor the + // browser can identify a safe filename. The API still enforces its MIME allowlist. + return "application/octet-stream"; }; /**