FEAT - OPS ATTACHMENTS: multi-format upload, stable card drop zone and text previews

This commit is contained in:
DCCONSTRUCTIONS
2026-08-29 16:08:02 +03:00
parent ca21a8f5bc
commit f9308539bf
5 changed files with 423 additions and 22 deletions
@@ -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
@@ -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
) : (
<Box className="size-5 text-[rgb(var(--nodedc-accent-rgb))]" />
)
) : previewType === "markdown" || previewType === "text" ? (
<FileText className="size-5 text-[rgb(var(--nodedc-accent-rgb))]" />
) : previewType === "file" ? (
getFileIcon(fileExtension, 18)
) : (
@@ -720,6 +737,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
) : (
<Box className="size-9 text-[rgb(var(--nodedc-accent-rgb))]" />
)
) : previewType === "markdown" || previewType === "text" ? (
<FileText className="size-9 text-[rgb(var(--nodedc-accent-rgb))]" />
) : 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) => {
</div>
) : previewType === "pdf" && previewURL ? (
<IssueAttachmentPdfPreview fileURL={previewURL} />
) : (previewType === "markdown" || previewType === "text") && previewURL ? (
<AttachmentTextPreview
fileName={fullFileName}
fileURL={previewURL}
isMarkdown={previewType === "markdown"}
size={size}
/>
) : isBeamAwaitingPreview || isBeamConversionFailed ? (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
<div
@@ -931,6 +957,93 @@ const AttachmentPreviewContent = (props: TAttachmentPreviewContent) => {
);
};
type TAttachmentTextPreview = {
fileName: string;
fileURL: string;
isMarkdown: boolean;
size: number;
};
const AttachmentTextPreview = (props: TAttachmentTextPreview) => {
const { fileName, fileURL, isMarkdown, size } = props;
const [content, setContent] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="flex h-full items-center justify-center p-8 text-center text-14 text-secondary">{error}</div>
);
}
if (renderedContent === null) {
return (
<div className="flex h-full items-center justify-center">
<Spinner
height="34px"
width="34px"
className="fill-[rgb(var(--nodedc-accent-rgb))] text-[rgba(var(--nodedc-accent-rgb),0.18)]"
/>
</div>
);
}
return (
<div className="vertical-scrollbar h-full overflow-auto bg-surface-1 px-6 py-5 sm:px-10 sm:py-8">
{isMarkdown ? (
<article className="nodedc-attachment-markdown-preview mx-auto max-w-4xl">
<MarkdownRenderer markdown={renderedContent} />
</article>
) : (
<pre className="font-mono m-0 min-h-full text-13 leading-6 break-words whitespace-pre-wrap text-secondary">
{renderedContent}
</pre>
)}
</div>
);
};
type TBeamVersionHistoryModal = {
currentVersionId: string | undefined;
deletingVersionKey: string | null;
@@ -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<HTMLElement>) => 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<TBeamViewerOpenEventDetail | null>(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<number>(() => {
if (typeof window === "undefined") return 720;
@@ -114,17 +121,158 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const initialMouseXRef = useRef<number>(0);
const livePeekWidthRef = useRef<number>(sidePeekWidth);
const beamCloseTimeoutRef = useRef<number | null>(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<HTMLDivElement>) => {
if (isCardAttachmentDropDisabled || !isFileDragEvent(event)) return;
event.preventDefault();
event.stopPropagation();
cardAttachmentDragDepthRef.current += 1;
setIsCardAttachmentDragActive(true);
},
[isCardAttachmentDropDisabled]
);
const handleCardDragOverCapture = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
if (isCardAttachmentDropDisabled || !isFileDragEvent(event)) return;
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "copy";
setIsCardAttachmentDragActive(true);
},
[isCardAttachmentDropDisabled]
);
const handleCardDragLeaveCapture = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 ? (
<div ref={issuePeekOverviewRef} className={peekOverviewIssueClassName} style={issuePanelStyle}>
<div
ref={issuePeekOverviewRef}
className={peekOverviewIssueClassName}
style={issuePanelStyle}
data-card-attachment-drop-active={isCardAttachmentDragActive ? "true" : "false"}
onDragEnterCapture={handleCardDragEnterCapture}
onDragOverCapture={handleCardDragOverCapture}
onDragLeaveCapture={handleCardDragLeaveCapture}
onDropCapture={handleCardDropCapture}
>
{isCardAttachmentDragActive && (
<div className="pointer-events-none absolute inset-0 z-[96] flex items-center justify-center overflow-hidden rounded-[inherit] bg-surface-2/88 p-6 backdrop-blur-md">
<div className="absolute inset-3 rounded-[22px] border border-dashed border-[#303432] bg-white/[0.01]" />
<div className="relative flex max-w-md flex-col items-center text-center">
<div className="grid size-16 place-items-center rounded-3xl bg-[rgba(var(--nodedc-accent-rgb),0.14)] text-[rgb(var(--nodedc-accent-rgb))]">
<UploadCloud className="size-7" />
</div>
<div className="text-17 mt-5 font-semibold text-primary">Отпустите файлы, чтобы прикрепить</div>
<div className="mt-2 text-13 text-secondary">
{fileSizeLimitEnabled
? `Можно несколько файлов за раз, до ${Math.round(maxFileSize / 1024 / 1024)} МБ каждый`
: "Можно прикрепить несколько файлов за раз"}
</div>
</div>
</div>
)}
{shouldAllowPeekResize && peekMode === "side-peek" && (
<div
className="absolute top-0 left-0 z-[81] h-full w-4 -translate-x-1/2 cursor-ew-resize rounded-l-[28px] bg-transparent"
+75
View File
@@ -7523,6 +7523,81 @@
color: var(--text-color-primary) !important;
}
.nodedc-attachment-markdown-preview {
color: var(--text-color-secondary);
line-height: 1.7;
}
.nodedc-attachment-markdown-preview > * + * {
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;
}
+38 -5
View File
@@ -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<Record<string, string>> = {
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<string> => {
};
/**
* @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<string>} validated and detected MIME type
*/
@@ -91,7 +117,7 @@ const validateAndDetectFileType = async (file: File): Promise<string> => {
// 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<string> => {
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";
};
/**