ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: filestorage upload и preview вложений
This commit is contained in:
@@ -53,7 +53,7 @@ export const IssueAttachmentsDetail = observer(function IssueAttachmentsDetail(p
|
||||
// derived values
|
||||
const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined;
|
||||
const fileName = getFileName(attachment?.attributes.name ?? "");
|
||||
const fileExtension = getFileExtension(attachment?.asset_url ?? "");
|
||||
const fileExtension = getFileExtension(attachment?.attributes.name ?? "");
|
||||
const fileIcon = getFileIcon(fileExtension, 28);
|
||||
const fileURL = getFileURL(attachment?.asset_url ?? "");
|
||||
// hooks
|
||||
|
||||
@@ -51,21 +51,33 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
|
||||
attachment: { getAttachmentsByIssueId },
|
||||
attachmentDeleteModalId,
|
||||
toggleDeleteAttachmentModal,
|
||||
fetchAttachments,
|
||||
fetchActivities,
|
||||
} = useIssueDetail(issueServiceType);
|
||||
const { operations: attachmentOperations, snapshot: attachmentSnapshot } = attachmentHelpers;
|
||||
const { create: createAttachment } = attachmentOperations;
|
||||
const { uploadStatus } = attachmentSnapshot;
|
||||
// file size
|
||||
const { maxFileSize } = useFileSize();
|
||||
const { fileSizeLimitEnabled, maxFileSize } = useFileSize();
|
||||
// derived values
|
||||
const issueAttachments = getAttachmentsByIssueId(issueId) ?? [];
|
||||
const activeUploads = uploadStatus ?? [];
|
||||
const hasAttachmentRows = issueAttachments.length > 0 || !!uploadStatus?.length;
|
||||
const uploadProgress =
|
||||
activeUploads.length > 0
|
||||
? Math.round(activeUploads.reduce((progressSum, item) => progressSum + item.progress, 0) / activeUploads.length)
|
||||
: 0;
|
||||
|
||||
// handlers
|
||||
const handleFetchPropertyActivities = useCallback(() => {
|
||||
fetchActivities(workspaceSlug, projectId, issueId);
|
||||
}, [fetchActivities, workspaceSlug, projectId, issueId]);
|
||||
const handleRefreshAttachments = useCallback(() => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
fetchAttachments(workspaceSlug, projectId, issueId).catch((error) => {
|
||||
console.error("Error in refreshing issue attachments after upload:", error);
|
||||
});
|
||||
}, [fetchAttachments, workspaceSlug, projectId, issueId]);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
|
||||
@@ -73,38 +85,39 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
|
||||
if (!workspaceSlug) return;
|
||||
setIsUploading(true);
|
||||
|
||||
Promise.allSettled(acceptedFiles.map((file) => createAttachment(file)))
|
||||
.then((results) => {
|
||||
const failedUploads = results.filter((result) => result.status === "rejected").length;
|
||||
if (failedUploads > 0)
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("attachment.error"),
|
||||
});
|
||||
return undefined;
|
||||
})
|
||||
.finally(() => {
|
||||
handleFetchPropertyActivities();
|
||||
setIsUploading(false);
|
||||
});
|
||||
Promise.allSettled(acceptedFiles.map((file) => createAttachment(file))).finally(() => {
|
||||
handleRefreshAttachments();
|
||||
window.setTimeout(handleRefreshAttachments, 1200);
|
||||
handleFetchPropertyActivities();
|
||||
setIsUploading(false);
|
||||
});
|
||||
}
|
||||
|
||||
if (rejectedFiles.length > 0)
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("attachment.file_size_limit", { size: maxFileSize / 1024 / 1024 }),
|
||||
message: fileSizeLimitEnabled
|
||||
? t("attachment.file_size_limit", { size: maxFileSize / 1024 / 1024 })
|
||||
: t("attachment.error"),
|
||||
});
|
||||
|
||||
return;
|
||||
},
|
||||
[createAttachment, maxFileSize, workspaceSlug, handleFetchPropertyActivities, t]
|
||||
[
|
||||
createAttachment,
|
||||
fileSizeLimitEnabled,
|
||||
maxFileSize,
|
||||
workspaceSlug,
|
||||
handleRefreshAttachments,
|
||||
handleFetchPropertyActivities,
|
||||
t,
|
||||
]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({
|
||||
onDrop,
|
||||
maxSize: maxFileSize,
|
||||
maxSize: fileSizeLimitEnabled ? maxFileSize : undefined,
|
||||
multiple: true,
|
||||
disabled: isUploading || disabled,
|
||||
});
|
||||
@@ -139,7 +152,9 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
|
||||
{isDragActive ? "Отпустите файлы здесь" : "Перетащите файлы сюда или нажмите для выбора"}
|
||||
</div>
|
||||
<div className="mt-1 truncate text-12 text-tertiary">
|
||||
Любой формат, несколько файлов за раз, до {maxFileSize / 1024 / 1024} МБ на файл
|
||||
{fileSizeLimitEnabled
|
||||
? `Любой формат, несколько файлов за раз, до ${maxFileSize / 1024 / 1024} МБ на файл`
|
||||
: "Любой формат, несколько файлов за раз, без ограничения размера"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,11 +163,22 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeUploads.length > 0 && (
|
||||
<div className="overflow-hidden rounded-full bg-white/6">
|
||||
<div
|
||||
className="h-1 rounded-full bg-[rgb(var(--nodedc-accent-rgb))] transition-[width] duration-200 ease-out"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasAttachmentRows && (
|
||||
<div className="rounded-[1.35rem] border border-subtle bg-surface-1/60 p-3 shadow-[0_12px_28px_rgba(0,0,0,0.08)]">
|
||||
<div className="mb-2 flex items-center justify-between gap-3 px-1">
|
||||
<div className="text-12 font-semibold uppercase tracking-[0.08em] text-tertiary">Вложения</div>
|
||||
<div className="text-12 text-tertiary">{issueAttachments.length}</div>
|
||||
<div className="text-12 font-semibold tracking-[0.08em] text-tertiary uppercase">Вложения</div>
|
||||
<div className="text-12 text-tertiary">
|
||||
{activeUploads.length > 0 ? `Загрузка ${uploadProgress}%` : issueAttachments.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 overflow-x-auto pb-1">
|
||||
{uploadStatus?.map((status) => (
|
||||
|
||||
@@ -17,11 +17,12 @@ import { EIssueServiceType } from "@plane/types";
|
||||
import type { TContextMenuItem } from "@plane/ui";
|
||||
import { ActionDropdown, EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils";
|
||||
import { Download, FileText, ImageIcon, Play, X } from "lucide-react";
|
||||
import { Download, ImageIcon, Play, X } from "lucide-react";
|
||||
// components
|
||||
//
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
import { getFileIcon } from "@/components/icons";
|
||||
import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attachment-pdf-preview";
|
||||
// helpers
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
@@ -38,6 +39,13 @@ const IMAGE_EXTENSIONS = new Set(["apng", "avif", "bmp", "gif", "jpg", "jpeg", "
|
||||
const VIDEO_EXTENSIONS = new Set(["avi", "m4v", "mov", "mp4", "mpeg", "mpg", "ogv", "webm"]);
|
||||
const PDF_EXTENSIONS = new Set(["pdf"]);
|
||||
|
||||
const appendSearchParam = (url: string | undefined, key: string, value: string): string => {
|
||||
if (!url) return "";
|
||||
const [urlWithoutHash, hash] = url.split("#");
|
||||
const separator = urlWithoutHash.includes("?") ? "&" : "?";
|
||||
return `${urlWithoutHash}${separator}${key}=${encodeURIComponent(value)}${hash ? `#${hash}` : ""}`;
|
||||
};
|
||||
|
||||
const getPreviewType = (extension: string) => {
|
||||
const normalizedExtension = extension.toLowerCase();
|
||||
if (IMAGE_EXTENSIONS.has(normalizedExtension)) return "image";
|
||||
@@ -64,7 +72,9 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
const previewType = getPreviewType(fileExtension);
|
||||
const fileIcon = getFileIcon(fileExtension, 32);
|
||||
const fileURL = getFileURL(attachment?.asset_url ?? "");
|
||||
const previewURL = appendSearchParam(fileURL, "preview", "true");
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [isThumbnailError, setIsThumbnailError] = useState(false);
|
||||
const menuItems: TContextMenuItem[] = [
|
||||
{
|
||||
key: "delete",
|
||||
@@ -93,19 +103,23 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
}}
|
||||
>
|
||||
<div className="relative h-full w-28 flex-shrink-0 overflow-hidden bg-surface-1">
|
||||
{previewType === "image" && fileURL ? (
|
||||
<img src={fileURL} alt={fullFileName} className="h-full w-full object-cover" loading="lazy" />
|
||||
) : previewType === "video" && fileURL ? (
|
||||
{previewType === "image" && previewURL && !isThumbnailError ? (
|
||||
<img
|
||||
src={previewURL}
|
||||
alt={fullFileName}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
onError={() => setIsThumbnailError(true)}
|
||||
/>
|
||||
) : previewType === "video" && previewURL ? (
|
||||
<>
|
||||
<video src={fileURL} className="h-full w-full object-cover" muted playsInline preload="metadata" />
|
||||
<video src={previewURL} className="h-full w-full object-cover" muted playsInline preload="metadata" />
|
||||
<div className="absolute inset-0 grid place-items-center bg-black/20 text-white">
|
||||
<Play className="size-6 fill-current" />
|
||||
</div>
|
||||
</>
|
||||
) : previewType === "pdf" && fileURL ? (
|
||||
<div className="flex h-full w-full items-center justify-center bg-white text-red-500">
|
||||
<FileText className="size-9" />
|
||||
</div>
|
||||
) : previewType === "pdf" && previewURL ? (
|
||||
<IssueAttachmentPdfThumbnail fileURL={previewURL} />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
{previewType === "file" ? fileIcon : <ImageIcon className="size-8 text-tertiary" />}
|
||||
@@ -154,10 +168,10 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
isOpen={isPreviewOpen}
|
||||
handleClose={() => setIsPreviewOpen(false)}
|
||||
position={EModalPosition.CENTER}
|
||||
width={EModalWidth.VIXL}
|
||||
className="overflow-hidden border border-subtle bg-surface-1"
|
||||
width={EModalWidth.VIIXL}
|
||||
className="h-[calc(100vh-4rem)] max-w-[calc(100vw-4rem)] overflow-hidden border border-subtle bg-surface-1"
|
||||
>
|
||||
<div className="relative min-h-[55vh] bg-surface-1 p-4">
|
||||
<div className="relative flex h-full min-h-0 flex-col bg-surface-1 p-4">
|
||||
<div className="absolute top-4 right-4 z-10 flex items-center gap-2">
|
||||
{fileURL && (
|
||||
<a
|
||||
@@ -180,27 +194,26 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pr-24">
|
||||
<div className="flex-shrink-0 pr-24">
|
||||
<div className="text-15 font-semibold text-primary">{fullFileName}</div>
|
||||
<div className="mt-1 text-12 text-tertiary">{convertBytesToSize(attachment.attributes.size)}</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex h-[70vh] max-h-[70vh] items-center justify-center overflow-hidden rounded-2xl bg-black/20">
|
||||
{previewType === "image" && fileURL ? (
|
||||
<img src={fileURL} alt={fullFileName} className="max-h-full max-w-full object-contain" />
|
||||
) : previewType === "video" && fileURL ? (
|
||||
<video src={fileURL} className="max-h-full max-w-full" controls autoPlay>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
) : previewType === "pdf" && fileURL ? (
|
||||
<iframe
|
||||
title={fullFileName}
|
||||
src={fileURL}
|
||||
sandbox="allow-downloads allow-same-origin"
|
||||
className="h-full w-full rounded-2xl bg-white"
|
||||
/>
|
||||
<div className="mt-4 min-h-0 flex-1 overflow-hidden rounded-2xl bg-black/20">
|
||||
{previewType === "image" && previewURL ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<img src={previewURL} alt={fullFileName} className="max-h-full max-w-full object-contain" />
|
||||
</div>
|
||||
) : previewType === "video" && previewURL ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<video src={previewURL} className="max-h-full max-w-full" controls autoPlay>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
</div>
|
||||
) : previewType === "pdf" && previewURL ? (
|
||||
<IssueAttachmentPdfPreview fileURL={previewURL} />
|
||||
) : (
|
||||
<div className="flex flex-col items-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 className="grid size-20 place-items-center rounded-3xl bg-surface-2">{fileIcon}</div>
|
||||
<div className="max-w-md text-14 text-secondary">
|
||||
Предпросмотр для этого формата недоступен. Файл можно скачать или открыть в новой вкладке.
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FileText, FileWarning, Loader2 } from "lucide-react";
|
||||
import type { PDFDocumentProxy } from "pdfjs-dist/types/src/display/api";
|
||||
|
||||
type PdfLoadingTask = {
|
||||
destroy?: () => void;
|
||||
promise: Promise<PDFDocumentProxy>;
|
||||
};
|
||||
|
||||
type PdfRenderTask = {
|
||||
cancel?: () => void;
|
||||
promise: Promise<unknown>;
|
||||
};
|
||||
|
||||
type PdfViewerProps = {
|
||||
fileURL: string;
|
||||
};
|
||||
|
||||
type PdfPageCanvasProps = {
|
||||
pageNumber: number;
|
||||
pdfDocument: PDFDocumentProxy;
|
||||
renderWidth: number;
|
||||
};
|
||||
|
||||
type PdfThumbnailProps = {
|
||||
fileURL: string;
|
||||
};
|
||||
|
||||
const MAX_PREVIEW_PAGE_WIDTH = 1040;
|
||||
const MIN_PREVIEW_PAGE_WIDTH = 360;
|
||||
|
||||
const configurePdfJs = async () => {
|
||||
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/build/pdf.worker.mjs", import.meta.url).toString();
|
||||
return pdfjs;
|
||||
};
|
||||
|
||||
const loadPdfDocument = async (fileURL: string, signal?: AbortSignal) => {
|
||||
const pdfjs = await configurePdfJs();
|
||||
let networkTask: PdfLoadingTask | undefined;
|
||||
|
||||
try {
|
||||
networkTask = pdfjs.getDocument({ url: fileURL, withCredentials: true });
|
||||
return {
|
||||
document: await networkTask.promise,
|
||||
loadingTask: networkTask,
|
||||
};
|
||||
} catch (networkError) {
|
||||
await networkTask?.destroy?.();
|
||||
if (signal?.aborted) throw networkError;
|
||||
|
||||
const response = await fetch(fileURL, { credentials: "include", signal });
|
||||
if (!response.ok) throw new Error(`PDF attachment request failed with ${response.status}`);
|
||||
|
||||
const pdfData = new Uint8Array(await response.arrayBuffer());
|
||||
const bytesTask = pdfjs.getDocument({ data: pdfData });
|
||||
return {
|
||||
document: await bytesTask.promise,
|
||||
loadingTask: bytesTask,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getOutputScale = () => Math.max(window.devicePixelRatio || 1, 1);
|
||||
|
||||
const PdfPageCanvas = (props: PdfPageCanvasProps) => {
|
||||
const { pageNumber, pdfDocument, renderWidth } = props;
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [isRendering, setIsRendering] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || renderWidth <= 0) return;
|
||||
|
||||
let isCancelled = false;
|
||||
let renderTask: PdfRenderTask | undefined;
|
||||
|
||||
const renderPage = async () => {
|
||||
setIsRendering(true);
|
||||
|
||||
try {
|
||||
const page = await pdfDocument.getPage(pageNumber);
|
||||
if (isCancelled) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const context = canvas?.getContext("2d");
|
||||
if (!canvas || !context) return;
|
||||
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const scale = renderWidth / viewport.width;
|
||||
const scaledViewport = page.getViewport({ scale });
|
||||
const outputScale = getOutputScale();
|
||||
|
||||
canvas.width = Math.floor(scaledViewport.width * outputScale);
|
||||
canvas.height = Math.floor(scaledViewport.height * outputScale);
|
||||
canvas.style.width = `${Math.floor(scaledViewport.width)}px`;
|
||||
canvas.style.height = `${Math.floor(scaledViewport.height)}px`;
|
||||
context.setTransform(outputScale, 0, 0, outputScale, 0, 0);
|
||||
context.clearRect(0, 0, scaledViewport.width, scaledViewport.height);
|
||||
|
||||
renderTask = page.render({ canvasContext: context, viewport: scaledViewport });
|
||||
await renderTask.promise;
|
||||
} finally {
|
||||
if (!isCancelled) setIsRendering(false);
|
||||
}
|
||||
};
|
||||
|
||||
renderPage().catch((error) => {
|
||||
if ((error as { name?: string })?.name !== "RenderingCancelledException") {
|
||||
console.error("Error in rendering PDF attachment page:", error);
|
||||
}
|
||||
if (!isCancelled) setIsRendering(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
renderTask?.cancel?.();
|
||||
};
|
||||
}, [pageNumber, pdfDocument, renderWidth]);
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full justify-center">
|
||||
{isRendering && (
|
||||
<div className="absolute inset-0 z-10 grid place-items-center rounded-sm bg-black/20 text-white">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<canvas ref={canvasRef} className="h-auto max-w-full rounded-sm bg-white shadow-[0_18px_60px_rgba(0,0,0,0.45)]" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const IssueAttachmentPdfThumbnail = (props: PdfThumbnailProps) => {
|
||||
const { fileURL } = props;
|
||||
const frameRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const pdfDocumentRef = useRef<PDFDocumentProxy | null>(null);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [renderTick, setRenderTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = frameRef.current;
|
||||
if (!frame) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => setRenderTick((current) => current + 1));
|
||||
resizeObserver.observe(frame);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileURL || !frameRef.current || !canvasRef.current) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
let isCancelled = false;
|
||||
let loadingTask: PdfLoadingTask | undefined;
|
||||
let renderTask: PdfRenderTask | undefined;
|
||||
|
||||
const renderThumbnail = async () => {
|
||||
setIsLoading(true);
|
||||
setHasError(false);
|
||||
|
||||
try {
|
||||
const loaded = await loadPdfDocument(fileURL, controller.signal);
|
||||
loadingTask = loaded.loadingTask;
|
||||
const document = loaded.document;
|
||||
|
||||
if (isCancelled) {
|
||||
document.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
pdfDocumentRef.current = document;
|
||||
|
||||
const page = await document.getPage(1);
|
||||
if (isCancelled) return;
|
||||
|
||||
const frame = frameRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
const context = canvas?.getContext("2d");
|
||||
if (!frame || !canvas || !context) return;
|
||||
|
||||
const frameWidth = Math.max(frame.clientWidth, 1);
|
||||
const frameHeight = Math.max(frame.clientHeight, 1);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const scale = Math.max(frameWidth / viewport.width, frameHeight / viewport.height);
|
||||
const scaledViewport = page.getViewport({ scale });
|
||||
const outputScale = getOutputScale();
|
||||
|
||||
canvas.width = Math.floor(scaledViewport.width * outputScale);
|
||||
canvas.height = Math.floor(scaledViewport.height * outputScale);
|
||||
canvas.style.width = `${Math.floor(scaledViewport.width)}px`;
|
||||
canvas.style.height = `${Math.floor(scaledViewport.height)}px`;
|
||||
context.setTransform(outputScale, 0, 0, outputScale, 0, 0);
|
||||
context.clearRect(0, 0, scaledViewport.width, scaledViewport.height);
|
||||
|
||||
renderTask = page.render({ canvasContext: context, viewport: scaledViewport });
|
||||
await renderTask.promise;
|
||||
} catch (error) {
|
||||
if (!isCancelled && (error as { name?: string })?.name !== "RenderingCancelledException") {
|
||||
console.error("Error in rendering PDF attachment thumbnail:", error);
|
||||
setHasError(true);
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
renderThumbnail();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
controller.abort();
|
||||
renderTask?.cancel?.();
|
||||
loadingTask?.destroy?.();
|
||||
pdfDocumentRef.current?.destroy();
|
||||
pdfDocumentRef.current = null;
|
||||
};
|
||||
}, [fileURL, renderTick]);
|
||||
|
||||
return (
|
||||
<div ref={frameRef} className="relative h-full w-full overflow-hidden bg-white">
|
||||
{!hasError && (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute top-1/2 left-1/2 max-w-none -translate-x-1/2 -translate-y-1/2 bg-white"
|
||||
/>
|
||||
)}
|
||||
{(isLoading || hasError) && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-white text-red-500">
|
||||
{isLoading ? <Loader2 className="size-7 animate-spin" /> : <FileText className="size-9" />}
|
||||
<span className="rounded-full bg-red-500 px-2 py-0.5 text-10 font-semibold text-white">PDF</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const IssueAttachmentPdfPreview = (props: PdfViewerProps) => {
|
||||
const { fileURL } = props;
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const pdfDocumentRef = useRef<PDFDocumentProxy | null>(null);
|
||||
const [pdfDocument, setPdfDocument] = useState<PDFDocumentProxy | null>(null);
|
||||
const [pageCount, setPageCount] = useState(0);
|
||||
const [renderWidth, setRenderWidth] = useState(MIN_PREVIEW_PAGE_WIDTH);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
const pageNumbers = useMemo(
|
||||
() => Array.from({ length: pageCount }, (_, index) => index + 1),
|
||||
[pageCount]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fileURL) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
let isCancelled = false;
|
||||
let loadingTask: PdfLoadingTask | undefined;
|
||||
|
||||
const loadDocument = async () => {
|
||||
setIsLoading(true);
|
||||
setHasError(false);
|
||||
setPdfDocument(null);
|
||||
setPageCount(0);
|
||||
|
||||
try {
|
||||
const loaded = await loadPdfDocument(fileURL, controller.signal);
|
||||
loadingTask = loaded.loadingTask;
|
||||
const document = loaded.document;
|
||||
|
||||
if (isCancelled) {
|
||||
document.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
pdfDocumentRef.current = document;
|
||||
setPdfDocument(document);
|
||||
setPageCount(document.numPages);
|
||||
} catch (error) {
|
||||
if (!isCancelled) {
|
||||
console.error("Error in loading PDF attachment preview:", error);
|
||||
setHasError(true);
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadDocument();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
controller.abort();
|
||||
loadingTask?.destroy?.();
|
||||
pdfDocumentRef.current?.destroy();
|
||||
pdfDocumentRef.current = null;
|
||||
};
|
||||
}, [fileURL]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const updateRenderWidth = () => {
|
||||
const width = Math.min(Math.max(container.clientWidth - 64, MIN_PREVIEW_PAGE_WIDTH), MAX_PREVIEW_PAGE_WIDTH);
|
||||
setRenderWidth(width);
|
||||
};
|
||||
|
||||
updateRenderWidth();
|
||||
const resizeObserver = new ResizeObserver(updateRenderWidth);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-8 text-center">
|
||||
<div className="text-red-400 grid size-20 place-items-center rounded-3xl bg-surface-2">
|
||||
<FileWarning className="size-10" />
|
||||
</div>
|
||||
<div className="max-w-md text-14 text-secondary">
|
||||
PDF не удалось открыть в предпросмотре. Проверьте загрузку файла или скачайте документ.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full min-w-0 min-h-0 flex-col bg-[#202020]">
|
||||
<div className="flex flex-shrink-0 items-center justify-center border-b border-white/10 bg-surface-1/90 px-4 py-3 text-12 font-semibold text-secondary">
|
||||
{isLoading ? "Загрузка PDF" : `${pageCount} стр.`}
|
||||
</div>
|
||||
|
||||
<div ref={containerRef} className="relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 z-10 grid place-items-center bg-black/25 text-white">
|
||||
<div className="flex items-center gap-2 rounded-full bg-black/45 px-4 py-2 text-13 font-medium backdrop-blur-md">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Открываем PDF
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pdfDocument && (
|
||||
<div className="mx-auto flex w-full max-w-[1120px] flex-col items-center gap-6 px-8 py-8">
|
||||
{pageNumbers.map((pageNumber) => (
|
||||
<PdfPageCanvas
|
||||
key={`${fileURL}-${pageNumber}-${renderWidth}`}
|
||||
pageNumber={pageNumber}
|
||||
pdfDocument={pdfDocument}
|
||||
renderWidth={renderWidth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -27,7 +27,7 @@ export const IssueAttachmentUpload = observer(function IssueAttachmentUpload(pro
|
||||
// states
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// file size
|
||||
const { maxFileSize } = useFileSize();
|
||||
const { fileSizeLimitEnabled, maxFileSize } = useFileSize();
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
@@ -42,13 +42,13 @@ export const IssueAttachmentUpload = observer(function IssueAttachmentUpload(pro
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({
|
||||
onDrop,
|
||||
maxSize: maxFileSize,
|
||||
maxSize: fileSizeLimitEnabled ? maxFileSize : undefined,
|
||||
multiple: false,
|
||||
disabled: isLoading || disabled,
|
||||
});
|
||||
|
||||
const fileError =
|
||||
fileRejections.length > 0
|
||||
fileRejections.length > 0 && fileSizeLimitEnabled
|
||||
? t("attachment_upload.invalid_file_type_or_size", { size: maxFileSize / 1024 / 1024 })
|
||||
: null;
|
||||
|
||||
|
||||
+9
-7
@@ -35,7 +35,7 @@ export const IssueAttachmentActionButton = observer(function IssueAttachmentActi
|
||||
// store hooks
|
||||
const { setLastWidgetAction, fetchActivities } = useIssueDetail(issueServiceType);
|
||||
// file size
|
||||
const { maxFileSize } = useFileSize();
|
||||
const { fileSizeLimitEnabled, maxFileSize } = useFileSize();
|
||||
// operations
|
||||
const { operations: attachmentOperations } = useAttachmentOperations(
|
||||
workspaceSlug,
|
||||
@@ -77,19 +77,21 @@ export const IssueAttachmentActionButton = observer(function IssueAttachmentActi
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message:
|
||||
totalAttachedFiles > 1
|
||||
? "Only one file can be uploaded at a time."
|
||||
: `File must be of ${maxFileSize / 1024 / 1024}MB or less in size.`,
|
||||
message:
|
||||
totalAttachedFiles > 1
|
||||
? "Only one file can be uploaded at a time."
|
||||
: fileSizeLimitEnabled
|
||||
? `File must be of ${maxFileSize / 1024 / 1024}MB or less in size.`
|
||||
: "File could not be attached. Try uploading again.",
|
||||
});
|
||||
return;
|
||||
},
|
||||
[attachmentOperations, maxFileSize, workspaceSlug, handleFetchPropertyActivities, setLastWidgetAction]
|
||||
[attachmentOperations, fileSizeLimitEnabled, maxFileSize, workspaceSlug, handleFetchPropertyActivities, setLastWidgetAction]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop,
|
||||
maxSize: maxFileSize,
|
||||
maxSize: fileSizeLimitEnabled ? maxFileSize : undefined,
|
||||
multiple: false,
|
||||
disabled: isLoading || disabled,
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
"use client";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||
import { SettingsIcon } from "lucide-react";
|
||||
import { ContextMenu } from "@plane/propel/context-menu";
|
||||
import { CheckIcon } from "@plane/propel/icons";
|
||||
@@ -25,11 +25,17 @@ export const AppRailRoot = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
// preferences
|
||||
const { preferences, updateDisplayMode } = useAppRailPreferences();
|
||||
const { isCollapsed, toggleAppRail } = useAppRailVisibility();
|
||||
// derived values
|
||||
const isWorkspaceSettingsPath = pathname.includes(`/${workspaceSlug}/settings`) && !projectId;
|
||||
const settingsModalSearchParams = new URLSearchParams(searchParams?.toString());
|
||||
settingsModalSearchParams.set("workspaceSettings", "general");
|
||||
const workspaceSettingsHref = `${pathname || `/${workspaceSlug}`}?${settingsModalSearchParams.toString()}`;
|
||||
const isWorkspaceSettingsPath =
|
||||
(pathname.includes(`/${workspaceSlug}/settings`) && !projectId) ||
|
||||
searchParams?.get("workspaceSettings") === "general";
|
||||
const showLabel = preferences.displayMode === "icon_with_label";
|
||||
const railWidth = showLabel ? "3.75rem" : "3rem";
|
||||
|
||||
@@ -57,7 +63,7 @@ export const AppRailRoot = observer(() => {
|
||||
item={{
|
||||
label: "Settings",
|
||||
icon: <SettingsIcon className="size-5" />,
|
||||
href: `/${workspaceSlug}/settings`,
|
||||
href: workspaceSettingsHref,
|
||||
isActive: isWorkspaceSettingsPath,
|
||||
showLabel,
|
||||
}}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// Plane Imports
|
||||
import { ORGANIZATION_SIZE, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { MAX_FILE_SIZE, ORGANIZATION_SIZE, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { EditIcon } from "@plane/propel/icons";
|
||||
@@ -32,6 +32,21 @@ const defaultValues: Partial<IWorkspace> = {
|
||||
organization_size: "2-10",
|
||||
logo_url: null,
|
||||
timezone: "UTC",
|
||||
storage_file_size_limit_enabled: true,
|
||||
storage_file_size_limit: MAX_FILE_SIZE,
|
||||
};
|
||||
|
||||
const BYTES_IN_MB = 1024 * 1024;
|
||||
|
||||
const bytesToMegabytes = (value?: number) => {
|
||||
if (!value || value < 1) return MAX_FILE_SIZE / BYTES_IN_MB;
|
||||
return Number((value / BYTES_IN_MB).toFixed(2));
|
||||
};
|
||||
|
||||
const megabytesToBytes = (value: string) => {
|
||||
const numericValue = Number(value);
|
||||
if (!Number.isFinite(numericValue) || numericValue <= 0) return 0;
|
||||
return Math.round(numericValue * BYTES_IN_MB);
|
||||
};
|
||||
|
||||
export const WorkspaceDetails = observer(function WorkspaceDetails() {
|
||||
@@ -55,6 +70,7 @@ export const WorkspaceDetails = observer(function WorkspaceDetails() {
|
||||
});
|
||||
// derived values
|
||||
const workspaceLogo = watch("logo_url");
|
||||
const storageFileSizeLimitEnabled = watch("storage_file_size_limit_enabled") ?? true;
|
||||
|
||||
const onSubmit = async (formData: IWorkspace) => {
|
||||
if (!currentWorkspace) return;
|
||||
@@ -65,6 +81,8 @@ export const WorkspaceDetails = observer(function WorkspaceDetails() {
|
||||
name: formData.name,
|
||||
organization_size: formData.organization_size,
|
||||
timezone: formData.timezone,
|
||||
storage_file_size_limit_enabled: formData.storage_file_size_limit_enabled,
|
||||
storage_file_size_limit: formData.storage_file_size_limit || MAX_FILE_SIZE,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -279,6 +297,96 @@ export const WorkspaceDetails = observer(function WorkspaceDetails() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="nodedc-settings-card flex flex-col gap-5 px-5 py-5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h3 className="text-15 font-semibold text-primary">Хранилище</h3>
|
||||
<p className="max-w-3xl text-12 leading-5 text-tertiary">
|
||||
Управляет ограничением веса файлов для вложений в задачах этого workspace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="storage_file_size_limit_enabled"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const isChecked = value ?? true;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-4 rounded-[1.25rem] bg-white/5 px-4 py-3 text-left transition",
|
||||
"hover:bg-white/8 focus-visible:bg-white/8",
|
||||
!isAdmin && "cursor-not-allowed opacity-70"
|
||||
)}
|
||||
onClick={() => isAdmin && onChange(!isChecked)}
|
||||
disabled={!isAdmin}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"grid size-4 flex-shrink-0 place-items-center rounded-full transition",
|
||||
isChecked ? "bg-[rgb(var(--nodedc-accent-rgb))]" : "bg-white/10"
|
||||
)}
|
||||
>
|
||||
{isChecked && (
|
||||
<span className="size-1.5 rounded-full bg-[rgb(var(--nodedc-on-accent-rgb))]" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-13 font-semibold text-primary">Ограничивать размер вложений</span>
|
||||
<span className="mt-0.5 block text-12 leading-5 text-tertiary">
|
||||
{isChecked
|
||||
? "В окне загрузки будет показан максимальный размер файла."
|
||||
: "Лимит снят: окно загрузки не будет ограничивать размер файла."}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden flex-shrink-0 rounded-full bg-white/6 px-3 py-1 text-11 font-semibold text-secondary sm:block">
|
||||
{isChecked ? "Лимит включен" : "Без лимита"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
{storageFileSizeLimitEnabled && (
|
||||
<div className="grid max-w-xl grid-cols-1 gap-2.5">
|
||||
<h4 className="text-body-sm-medium text-tertiary">Максимальный размер файла, МБ</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="storage_file_size_limit"
|
||||
rules={{
|
||||
validate: (value) =>
|
||||
!storageFileSizeLimitEnabled ||
|
||||
(Number(value) > 0 && Number.isFinite(Number(value))) ||
|
||||
"Укажите лимит больше 0 МБ",
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="storage_file_size_limit"
|
||||
name="storage_file_size_limit"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={bytesToMegabytes(value)}
|
||||
onChange={(event) => onChange(megabytesToBytes(event.target.value))}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.storage_file_size_limit)}
|
||||
placeholder="Например, 25"
|
||||
className="nodedc-settings-input w-full"
|
||||
disabled={!isAdmin}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.storage_file_size_limit && (
|
||||
<p className="text-caption-sm-regular text-danger-primary">
|
||||
{errors.storage_file_size_limit.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { X } from "lucide-react";
|
||||
// plane imports
|
||||
import { ScrollArea } from "@plane/propel/scrollarea";
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
// components
|
||||
import { WorkspaceDetails } from "@/components/workspace/settings/workspace-details";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
|
||||
export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const activeModal = searchParams.get("workspaceSettings");
|
||||
const isOpen = activeModal === "general";
|
||||
|
||||
const handleClose = () => {
|
||||
const nextSearchParams = new URLSearchParams(location.search);
|
||||
nextSearchParams.delete("workspaceSettings");
|
||||
|
||||
navigate(
|
||||
{
|
||||
pathname: location.pathname,
|
||||
search: nextSearchParams.toString() ? `?${nextSearchParams.toString()}` : "",
|
||||
hash: location.hash,
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalCore
|
||||
isOpen={isOpen}
|
||||
handleClose={handleClose}
|
||||
position={EModalPosition.CENTER}
|
||||
width={EModalWidth.VIIXL}
|
||||
className="h-[88vh] max-h-[920px] overflow-hidden border border-white/8 bg-[rgba(12,12,16,0.94)]"
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex shrink-0 items-center justify-between gap-4 border-b border-white/6 px-6 py-5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-18 font-semibold text-primary">Настройки workspace</div>
|
||||
<div className="mt-1 truncate text-12 text-tertiary">
|
||||
{currentWorkspace?.name ?? "Workspace"} / основные параметры
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="grid size-10 flex-shrink-0 place-items-center rounded-full bg-white/6 text-secondary transition hover:bg-white/10 hover:text-primary"
|
||||
aria-label="Закрыть настройки workspace"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ScrollArea scrollType="hover" orientation="vertical" size="sm" className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-[74rem] px-5 py-6 lg:px-8">
|
||||
<WorkspaceDetails />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||
import { Settings, UserPlus } from "lucide-react";
|
||||
import { Menu } from "@headlessui/react";
|
||||
// plane imports
|
||||
@@ -29,8 +29,13 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
|
||||
const { workspace, activeWorkspace, handleItemClick, handleWorkspaceNavigation, handleClose } = props;
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
// hooks
|
||||
const { t } = useTranslation();
|
||||
const settingsModalSearchParams = new URLSearchParams(searchParams?.toString());
|
||||
settingsModalSearchParams.set("workspaceSettings", "general");
|
||||
const workspaceSettingsHref = `${pathname || `/${workspace.slug}`}?${settingsModalSearchParams.toString()}`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -93,7 +98,7 @@ const SidebarDropdownItem = observer(function SidebarDropdownItem(props: TProps)
|
||||
<div className="mt-2 mb-1 grid grid-cols-2 gap-3">
|
||||
{[EUserPermissions.ADMIN, EUserPermissions.MEMBER].includes(workspace?.role) && (
|
||||
<Link
|
||||
href={`/${workspace.slug}/settings`}
|
||||
href={workspaceSettingsHref}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClose();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useParams, usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { MoreHorizontal, ArchiveIcon, Settings } from "lucide-react";
|
||||
import { Disclosure } from "@headlessui/react";
|
||||
// plane imports
|
||||
@@ -32,12 +32,17 @@ export const SidebarWorkspaceMenuHeader = observer(function SidebarWorkspaceMenu
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
// hooks
|
||||
const { workspaceSlug } = useParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { t } = useTranslation();
|
||||
// TODO: fix types
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const isAdmin = allowPermissions([EUserWorkspaceRoles.ADMIN] as any, EUserPermissionsLevel.WORKSPACE);
|
||||
const settingsModalSearchParams = new URLSearchParams(searchParams?.toString());
|
||||
settingsModalSearchParams.set("workspaceSettings", "general");
|
||||
const workspaceSettingsHref = `${pathname || `/${workspaceSlug}`}?${settingsModalSearchParams.toString()}`;
|
||||
|
||||
return (
|
||||
<div className="group/workspace-button mt-2.5 flex rounded-sm bg-surface-1 px-2 hover:bg-surface-2">
|
||||
@@ -74,7 +79,7 @@ export const SidebarWorkspaceMenuHeader = observer(function SidebarWorkspaceMenu
|
||||
key: "settings",
|
||||
title: t("settings"),
|
||||
icon: Settings,
|
||||
action: () => router.push(`/${workspaceSlug}/settings`),
|
||||
action: () => router.push(workspaceSettingsHref),
|
||||
shouldRender: isAdmin,
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -35,7 +35,7 @@ export class FileUploadService extends APIService {
|
||||
if (axios.isCancel(error)) {
|
||||
console.log(error.message);
|
||||
} else {
|
||||
throw error?.response?.data;
|
||||
throw error?.response?.data ?? error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -55,16 +55,32 @@ export class IssueAttachmentService extends APIService {
|
||||
.then(async (response) => {
|
||||
const signedURLResponse: TIssueAttachmentUploadResponse = response?.data;
|
||||
const fileUploadPayload = generateFileUploadPayload(signedURLResponse, file);
|
||||
await this.fileUploadService.uploadFile(
|
||||
signedURLResponse.upload_data.url,
|
||||
fileUploadPayload,
|
||||
uploadProgressHandler
|
||||
);
|
||||
await this.updateIssueAttachmentUploadStatus(workspaceSlug, projectId, issueId, signedURLResponse.asset_id);
|
||||
let uploadError: unknown;
|
||||
try {
|
||||
await this.fileUploadService.uploadFile(
|
||||
signedURLResponse.upload_data.url,
|
||||
fileUploadPayload,
|
||||
uploadProgressHandler
|
||||
);
|
||||
} catch (error) {
|
||||
uploadError = error;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.updateIssueAttachmentUploadStatus(workspaceSlug, projectId, issueId, signedURLResponse.asset_id);
|
||||
} catch (error) {
|
||||
await this.deleteIssueAttachment(workspaceSlug, projectId, issueId, signedURLResponse.asset_id).catch(
|
||||
(deleteError) => {
|
||||
console.error("Error in cleaning failed issue attachment upload:", deleteError);
|
||||
}
|
||||
);
|
||||
throw uploadError ?? error;
|
||||
}
|
||||
|
||||
return signedURLResponse.attachment;
|
||||
})
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
throw error?.response?.data ?? error;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -74,7 +90,7 @@ export class IssueAttachmentService extends APIService {
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
throw error?.response?.data ?? error;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,7 +105,7 @@ export class IssueAttachmentService extends APIService {
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
throw error?.response?.data ?? error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { uniq, pull, set, debounce, update, concat } from "lodash-es";
|
||||
import { uniq, pull, set, debounce, update } from "lodash-es";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
@@ -23,6 +23,17 @@ export type TAttachmentUploadStatus = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
const getAttachmentTimestamp = (attachment: TIssueAttachment) =>
|
||||
new Date(attachment.created_at ?? attachment.updated_at ?? 0).getTime();
|
||||
|
||||
const sortAttachmentIdsByNewest = (attachmentIds: string[], attachmentMap: TIssueAttachmentMap): string[] =>
|
||||
[...attachmentIds].sort((firstId, secondId) => {
|
||||
const firstAttachment = attachmentMap[firstId];
|
||||
const secondAttachment = attachmentMap[secondId];
|
||||
if (!firstAttachment || !secondAttachment) return 0;
|
||||
return getAttachmentTimestamp(secondAttachment) - getAttachmentTimestamp(firstAttachment);
|
||||
});
|
||||
|
||||
export interface IIssueAttachmentStoreActions {
|
||||
// actions
|
||||
addAttachments: (issueId: string, attachments: TIssueAttachment[]) => void;
|
||||
@@ -119,17 +130,31 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
|
||||
// actions
|
||||
addAttachments = (issueId: string, attachments: TIssueAttachment[]) => {
|
||||
if (attachments && attachments.length > 0) {
|
||||
const newAttachmentIds = attachments.map((attachment) => attachment.id);
|
||||
runInAction(() => {
|
||||
update(this.attachments, [issueId], (attachmentIds = []) => uniq(concat(attachmentIds, newAttachmentIds)));
|
||||
attachments.forEach((attachment) => set(this.attachmentMap, attachment.id, attachment));
|
||||
update(this.attachments, [issueId], (attachmentIds = []) =>
|
||||
sortAttachmentIdsByNewest(
|
||||
uniq([...attachments.map((attachment) => attachment.id), ...attachmentIds]),
|
||||
this.attachmentMap
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
fetchAttachments = async (workspaceSlug: string, projectId: string, issueId: string) => {
|
||||
const response = await this.issueAttachmentService.getIssueAttachments(workspaceSlug, projectId, issueId);
|
||||
this.addAttachments(issueId, response);
|
||||
runInAction(() => {
|
||||
response.forEach((attachment) => set(this.attachmentMap, attachment.id, attachment));
|
||||
set(
|
||||
this.attachments,
|
||||
issueId,
|
||||
sortAttachmentIdsByNewest(
|
||||
response.map((attachment) => attachment.id),
|
||||
this.attachmentMap
|
||||
)
|
||||
);
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
@@ -164,13 +189,23 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
|
||||
);
|
||||
|
||||
if (response && response.id) {
|
||||
runInAction(() => {
|
||||
update(this.attachments, [issueId], (attachmentIds = []) => uniq(concat(attachmentIds, [response.id])));
|
||||
set(this.attachmentMap, response.id, response);
|
||||
this.rootIssueStore.issues.updateIssue(issueId, {
|
||||
attachment_count: this.getAttachmentsCountByIssueId(issueId),
|
||||
try {
|
||||
runInAction(() => {
|
||||
set(this.attachmentsUploadStatusMap, [issueId, tempId, "progress"], 100);
|
||||
});
|
||||
});
|
||||
const syncedAttachments = await this.fetchAttachments(workspaceSlug, projectId, issueId).catch((error) => {
|
||||
console.error("Error in fetching issue attachments after upload:", error);
|
||||
return undefined;
|
||||
});
|
||||
runInAction(() => {
|
||||
this.rootIssueStore.issues.updateIssue(issueId, {
|
||||
attachment_count: this.getAttachmentsCountByIssueId(issueId),
|
||||
});
|
||||
});
|
||||
return syncedAttachments?.find((attachment) => attachment.id === response.id) ?? response;
|
||||
} catch (syncError) {
|
||||
console.error("Error in syncing issue attachment after upload:", syncError);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
@@ -179,7 +214,7 @@ export class IssueAttachmentStore implements IIssueAttachmentStore {
|
||||
throw error;
|
||||
} finally {
|
||||
runInAction(() => {
|
||||
delete this.attachmentsUploadStatusMap[issueId][tempId];
|
||||
if (this.attachmentsUploadStatusMap[issueId]) delete this.attachmentsUploadStatusMap[issueId][tempId];
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user