FEAT - OPS ATTACHMENTS: multi-format upload, stable card drop zone and text previews
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user