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/css",
|
||||||
"text/javascript",
|
"text/javascript",
|
||||||
"application/json",
|
"application/json",
|
||||||
|
"application/x-ndjson",
|
||||||
|
"application/yaml",
|
||||||
|
"application/x-yaml",
|
||||||
|
"text/yaml",
|
||||||
|
"application/toml",
|
||||||
"text/xml",
|
"text/xml",
|
||||||
"text/csv",
|
"text/csv",
|
||||||
|
"text/tab-separated-values",
|
||||||
"application/xml",
|
"application/xml",
|
||||||
# SQL
|
# SQL
|
||||||
"application/x-sql",
|
"application/x-sql",
|
||||||
@@ -470,6 +476,7 @@ ATTACHMENT_MIME_TYPES = [
|
|||||||
"application/x-gzip",
|
"application/x-gzip",
|
||||||
# Markdown
|
# Markdown
|
||||||
"text/markdown",
|
"text/markdown",
|
||||||
|
"text/x-markdown",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Seed directory path
|
# Seed directory path
|
||||||
|
|||||||
@@ -19,11 +19,12 @@ import { EIssueServiceType } from "@plane/types";
|
|||||||
import type { TContextMenuItem } from "@plane/ui";
|
import type { TContextMenuItem } from "@plane/ui";
|
||||||
import { ActionDropdown, EModalPosition, EModalWidth, ModalCore, Spinner } from "@plane/ui";
|
import { ActionDropdown, EModalPosition, EModalWidth, ModalCore, Spinner } from "@plane/ui";
|
||||||
import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils";
|
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
|
// components
|
||||||
//
|
//
|
||||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||||
import { getFileIcon } from "@/components/icons";
|
import { getFileIcon } from "@/components/icons";
|
||||||
|
import { MarkdownRenderer } from "@/components/ui/markdown-to-component";
|
||||||
import {
|
import {
|
||||||
buildBeamViewerUrl,
|
buildBeamViewerUrl,
|
||||||
dispatchBeamViewerOpenEvent,
|
dispatchBeamViewerOpenEvent,
|
||||||
@@ -57,6 +58,26 @@ type TIssueAttachmentsListItem = {
|
|||||||
const IMAGE_EXTENSIONS = new Set(["apng", "avif", "bmp", "gif", "jpg", "jpeg", "png", "svg", "webp"]);
|
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 VIDEO_EXTENSIONS = new Set(["avi", "m4v", "mov", "mp4", "mpeg", "mpg", "ogv", "webm"]);
|
||||||
const PDF_EXTENSIONS = new Set(["pdf"]);
|
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 => {
|
const appendSearchParam = (url: string | undefined, key: string, value: string): string => {
|
||||||
if (!url) return "";
|
if (!url) return "";
|
||||||
@@ -94,11 +115,13 @@ const withBeamViewerSettingsSrc = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPreviewType = (extension: string) => {
|
const getPreviewType = (extension: string): TAttachmentPreviewType => {
|
||||||
const normalizedExtension = extension.toLowerCase();
|
const normalizedExtension = extension.toLowerCase();
|
||||||
if (IMAGE_EXTENSIONS.has(normalizedExtension)) return "image";
|
if (IMAGE_EXTENSIONS.has(normalizedExtension)) return "image";
|
||||||
if (VIDEO_EXTENSIONS.has(normalizedExtension)) return "video";
|
if (VIDEO_EXTENSIONS.has(normalizedExtension)) return "video";
|
||||||
if (PDF_EXTENSIONS.has(normalizedExtension)) return "pdf";
|
if (PDF_EXTENSIONS.has(normalizedExtension)) return "pdf";
|
||||||
|
if (MARKDOWN_EXTENSIONS.has(normalizedExtension)) return "markdown";
|
||||||
|
if (TEXT_EXTENSIONS.has(normalizedExtension)) return "text";
|
||||||
return "file";
|
return "file";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -170,8 +193,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||||||
})
|
})
|
||||||
: undefined) || storedModelViewerUrlWithSettings;
|
: undefined) || storedModelViewerUrlWithSettings;
|
||||||
const canOpenModelViewer =
|
const canOpenModelViewer =
|
||||||
!!beamViewer &&
|
!!beamViewer && (beamViewer.previewAvailable || beamEffectiveStatus === "ready" || !!modelViewerUrl);
|
||||||
(beamViewer.previewAvailable || beamEffectiveStatus === "ready" || !!modelViewerUrl);
|
|
||||||
const modelDownloadUrl = beamViewer?.downloadUrl || beamViewer?.src;
|
const modelDownloadUrl = beamViewer?.downloadUrl || beamViewer?.src;
|
||||||
const previewDownloadUrl = modelDownloadUrl || fileURL;
|
const previewDownloadUrl = modelDownloadUrl || fileURL;
|
||||||
const isBeamModel = isBeamModelFile(fullFileName);
|
const isBeamModel = isBeamModelFile(fullFileName);
|
||||||
@@ -210,7 +232,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||||||
() => mergeBeamModelVersionRecordLists(localBeamVersions, liveBeamVersions),
|
() => mergeBeamModelVersionRecordLists(localBeamVersions, liveBeamVersions),
|
||||||
[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 =
|
const versionLabel =
|
||||||
typeof rawVersion === "number"
|
typeof rawVersion === "number"
|
||||||
? `v${rawVersion}`
|
? `v${rawVersion}`
|
||||||
@@ -476,15 +499,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||||||
isMounted = false;
|
isMounted = false;
|
||||||
if (timeoutId) window.clearTimeout(timeoutId);
|
if (timeoutId) window.clearTimeout(timeoutId);
|
||||||
};
|
};
|
||||||
}, [
|
}, [attachmentId, attachmentService, beamViewer, issueId, projectId, storedModelViewerUrl, workspaceSlug]);
|
||||||
attachmentId,
|
|
||||||
attachmentService,
|
|
||||||
beamViewer,
|
|
||||||
issueId,
|
|
||||||
projectId,
|
|
||||||
storedModelViewerUrl,
|
|
||||||
workspaceSlug,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!attachment) return <></>;
|
if (!attachment) return <></>;
|
||||||
|
|
||||||
@@ -523,6 +538,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||||||
) : (
|
) : (
|
||||||
<Box className="size-5 text-[rgb(var(--nodedc-accent-rgb))]" />
|
<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" ? (
|
) : previewType === "file" ? (
|
||||||
getFileIcon(fileExtension, 18)
|
getFileIcon(fileExtension, 18)
|
||||||
) : (
|
) : (
|
||||||
@@ -720,6 +737,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
|||||||
) : (
|
) : (
|
||||||
<Box className="size-9 text-[rgb(var(--nodedc-accent-rgb))]" />
|
<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" ? (
|
) : previewType === "file" ? (
|
||||||
fileIcon
|
fileIcon
|
||||||
) : (
|
) : (
|
||||||
@@ -812,7 +831,7 @@ type TAttachmentPreviewContent = {
|
|||||||
isBeamConversionFailed: boolean;
|
isBeamConversionFailed: boolean;
|
||||||
modelDownloadUrl: string | undefined;
|
modelDownloadUrl: string | undefined;
|
||||||
previewDownloadUrl: string | undefined;
|
previewDownloadUrl: string | undefined;
|
||||||
previewType: "image" | "video" | "pdf" | "file";
|
previewType: TAttachmentPreviewType;
|
||||||
previewURL: string;
|
previewURL: string;
|
||||||
setIsPreviewOpen: (isOpen: boolean) => void;
|
setIsPreviewOpen: (isOpen: boolean) => void;
|
||||||
size: number;
|
size: number;
|
||||||
@@ -876,6 +895,13 @@ const AttachmentPreviewContent = (props: TAttachmentPreviewContent) => {
|
|||||||
</div>
|
</div>
|
||||||
) : previewType === "pdf" && previewURL ? (
|
) : previewType === "pdf" && previewURL ? (
|
||||||
<IssueAttachmentPdfPreview fileURL={previewURL} />
|
<IssueAttachmentPdfPreview fileURL={previewURL} />
|
||||||
|
) : (previewType === "markdown" || previewType === "text") && previewURL ? (
|
||||||
|
<AttachmentTextPreview
|
||||||
|
fileName={fullFileName}
|
||||||
|
fileURL={previewURL}
|
||||||
|
isMarkdown={previewType === "markdown"}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
) : isBeamAwaitingPreview || isBeamConversionFailed ? (
|
) : isBeamAwaitingPreview || isBeamConversionFailed ? (
|
||||||
<div className="flex h-full flex-col items-center justify-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
|
<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 = {
|
type TBeamVersionHistoryModal = {
|
||||||
currentVersionId: string | undefined;
|
currentVersionId: string | undefined;
|
||||||
deletingVersionKey: string | null;
|
deletingVersionKey: string | null;
|
||||||
|
|||||||
@@ -10,14 +10,16 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
|
type DragEvent as ReactDragEvent,
|
||||||
type MouseEvent as ReactMouseEvent,
|
type MouseEvent as ReactMouseEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Download, Maximize2, Minimize2, X } from "lucide-react";
|
import { Download, Maximize2, Minimize2, UploadCloud, X } from "lucide-react";
|
||||||
// plane imports
|
// plane imports
|
||||||
import type { EditorRefApi } from "@plane/editor";
|
import type { EditorRefApi } from "@plane/editor";
|
||||||
|
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||||
import type { TNameDescriptionLoader } from "@plane/types";
|
import type { TNameDescriptionLoader } from "@plane/types";
|
||||||
import { EIssueServiceType } from "@plane/types";
|
import { EIssueServiceType } from "@plane/types";
|
||||||
import { cn, convertBytesToSize } from "@plane/utils";
|
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 { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||||
import useKeypress from "@/hooks/use-keypress";
|
import useKeypress from "@/hooks/use-keypress";
|
||||||
import usePeekOverviewOutsideClickDetector from "@/hooks/use-peek-overview-outside-click";
|
import usePeekOverviewOutsideClickDetector from "@/hooks/use-peek-overview-outside-click";
|
||||||
|
import { useFileSize } from "@/plane-web/hooks/use-file-size";
|
||||||
// local imports
|
// local imports
|
||||||
import type { TIssueOperations } from "../issue-detail";
|
import type { TIssueOperations } from "../issue-detail";
|
||||||
import { IssueActivity } from "../issue-detail/issue-activity";
|
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 SIDE_PEEK_WIDTH_STORAGE_KEY = "nodedc:issue-peek-width";
|
||||||
const BEAM_VIEWER_CLOSING_CLASS_NAME = "nodedc-beam-viewer-closing";
|
const BEAM_VIEWER_CLOSING_CLASS_NAME = "nodedc-beam-viewer-closing";
|
||||||
|
|
||||||
|
const isFileDragEvent = (event: ReactDragEvent<HTMLElement>) => Array.from(event.dataTransfer.types).includes("Files");
|
||||||
|
|
||||||
interface IIssueView {
|
interface IIssueView {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -96,6 +101,8 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
|||||||
const [beamPeekViewer, setBeamPeekViewer] = useState<TBeamViewerOpenEventDetail | null>(null);
|
const [beamPeekViewer, setBeamPeekViewer] = useState<TBeamViewerOpenEventDetail | null>(null);
|
||||||
const [isBeamPeekFullscreen, setIsBeamPeekFullscreen] = useState(false);
|
const [isBeamPeekFullscreen, setIsBeamPeekFullscreen] = useState(false);
|
||||||
const [isBeamPeekClosing, setIsBeamPeekClosing] = useState(false);
|
const [isBeamPeekClosing, setIsBeamPeekClosing] = useState(false);
|
||||||
|
const [isCardAttachmentUploading, setIsCardAttachmentUploading] = useState(false);
|
||||||
|
const [isCardAttachmentDragActive, setIsCardAttachmentDragActive] = useState(false);
|
||||||
const [sidePeekWidth, setSidePeekWidth] = useState<number>(() => {
|
const [sidePeekWidth, setSidePeekWidth] = useState<number>(() => {
|
||||||
if (typeof window === "undefined") return 720;
|
if (typeof window === "undefined") return 720;
|
||||||
|
|
||||||
@@ -114,17 +121,158 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
|||||||
const initialMouseXRef = useRef<number>(0);
|
const initialMouseXRef = useRef<number>(0);
|
||||||
const livePeekWidthRef = useRef<number>(sidePeekWidth);
|
const livePeekWidthRef = useRef<number>(sidePeekWidth);
|
||||||
const beamCloseTimeoutRef = useRef<number | null>(null);
|
const beamCloseTimeoutRef = useRef<number | null>(null);
|
||||||
|
const cardAttachmentDragDepthRef = useRef(0);
|
||||||
// store hooks
|
// store hooks
|
||||||
const {
|
const {
|
||||||
setPeekIssue,
|
setPeekIssue,
|
||||||
isAnyModalOpen,
|
isAnyModalOpen,
|
||||||
|
createAttachment,
|
||||||
|
fetchActivities,
|
||||||
|
fetchAttachments,
|
||||||
issue: { getIssueById },
|
issue: { getIssueById },
|
||||||
} = useIssueDetail();
|
} = useIssueDetail();
|
||||||
const { isAnyModalOpen: isAnyEpicModalOpen } = useIssueDetail(EIssueServiceType.EPICS);
|
const { isAnyModalOpen: isAnyEpicModalOpen } = useIssueDetail(EIssueServiceType.EPICS);
|
||||||
|
const { fileSizeLimitEnabled, maxFileSize } = useFileSize();
|
||||||
const issue = getIssueById(issueId);
|
const issue = getIssueById(issueId);
|
||||||
const shouldUseInteractiveEmbeddedLayout = embedIssue && interactiveEmbeddedLayout;
|
const shouldUseInteractiveEmbeddedLayout = embedIssue && interactiveEmbeddedLayout;
|
||||||
const shouldRenderPeekSurface = !embedIssue || shouldUseInteractiveEmbeddedLayout;
|
const shouldRenderPeekSurface = !embedIssue || shouldUseInteractiveEmbeddedLayout;
|
||||||
const shouldAllowPeekResize = !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
|
// remove peek id
|
||||||
const removeRoutePeekId = () => {
|
const removeRoutePeekId = () => {
|
||||||
setPeekIssue(undefined);
|
setPeekIssue(undefined);
|
||||||
@@ -407,7 +555,32 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const issuePanel = issueId ? (
|
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" && (
|
{shouldAllowPeekResize && peekMode === "side-peek" && (
|
||||||
<div
|
<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"
|
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;
|
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"] {
|
.nodedc-attachments-panel[data-view-mode="list"] {
|
||||||
padding-bottom: 0.75rem;
|
padding-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,30 @@ import { fileTypeFromBuffer } from "file-type";
|
|||||||
import type { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types";
|
import type { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types";
|
||||||
import { DANGEROUS_EXTENSIONS } from "@plane/constants";
|
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
|
* @description Filename validation - checks for double extensions and dangerous patterns
|
||||||
* @param {string} filename
|
* @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
|
* @description Validate and detect the MIME type of a file.
|
||||||
* Also performs basic security checks on filename
|
* 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
|
* @param {File} file
|
||||||
* @returns {Promise<string>} validated and detected MIME type
|
* @returns {Promise<string>} validated and detected MIME type
|
||||||
*/
|
*/
|
||||||
@@ -91,7 +117,7 @@ const validateAndDetectFileType = async (file: File): Promise<string> => {
|
|||||||
// Basic filename validation
|
// Basic filename validation
|
||||||
const filenameError = validateFilename(file.name);
|
const filenameError = validateFilename(file.name);
|
||||||
if (filenameError) {
|
if (filenameError) {
|
||||||
console.warn(`File validation warning: ${filenameError}`);
|
throw new Error(filenameError);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -103,8 +129,15 @@ const validateAndDetectFileType = async (file: File): Promise<string> => {
|
|||||||
console.warn("Error detecting file type from signature:", _error);
|
console.warn("Error detecting file type from signature:", _error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallback for unknown files
|
const extensionMimeType = TEXT_MIME_TYPES_BY_EXTENSION[getFileExtension(file.name)];
|
||||||
return "";
|
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