beam: integrate model attachments with Beam viewer
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { getIconButtonStyling } from "@plane/propel/icon-button";
|
||||
@@ -17,11 +17,18 @@ 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, ImageIcon, Play, X } from "lucide-react";
|
||||
import { Box, Download, Eye, ImageIcon, Play, X } from "lucide-react";
|
||||
// components
|
||||
//
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
import { getFileIcon } from "@/components/icons";
|
||||
import {
|
||||
buildBeamViewerUrl,
|
||||
fetchBeamConversionStatus,
|
||||
getBeamViewerAttachment,
|
||||
isBeamModelFile,
|
||||
type TBeamConversionStatus,
|
||||
} from "@/helpers/beam-viewer";
|
||||
import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attachment-pdf-preview";
|
||||
// helpers
|
||||
// hooks
|
||||
@@ -54,6 +61,14 @@ const getPreviewType = (extension: string) => {
|
||||
return "file";
|
||||
};
|
||||
|
||||
const getBeamConversionStatusLabel = (status: string | undefined): string | null => {
|
||||
if (!status) return null;
|
||||
if (status === "ready") return "Модель готова";
|
||||
if (status === "failed") return "Ошибка дерева";
|
||||
if (status === "processing") return "Собираем дерево";
|
||||
return "Ожидает дерево";
|
||||
};
|
||||
|
||||
export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) {
|
||||
const { t } = useTranslation();
|
||||
// props
|
||||
@@ -73,9 +88,53 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
const fileIcon = getFileIcon(fileExtension, 32);
|
||||
const fileURL = getFileURL(attachment?.asset_url ?? "");
|
||||
const previewURL = appendSearchParam(fileURL, "preview", "true");
|
||||
const beamViewer = getBeamViewerAttachment(attachment);
|
||||
const [beamConversionStatus, setBeamConversionStatus] = useState<TBeamConversionStatus | null>(null);
|
||||
const resolvedArtifactUrl = beamConversionStatus?.status === "ready" ? beamConversionStatus.artifactUrl : undefined;
|
||||
const modelViewerUrl =
|
||||
beamViewer?.viewerUrl ||
|
||||
(resolvedArtifactUrl
|
||||
? buildBeamViewerUrl({
|
||||
name: fullFileName,
|
||||
src: resolvedArtifactUrl,
|
||||
type: beamConversionStatus?.artifactType || "gltf",
|
||||
})
|
||||
: undefined);
|
||||
const modelDownloadUrl = beamViewer?.downloadUrl || beamViewer?.src;
|
||||
const previewDownloadUrl = modelDownloadUrl || fileURL;
|
||||
const beamConversionStatusLabel = getBeamConversionStatusLabel(
|
||||
beamConversionStatus?.status ?? beamViewer?.conversion?.status
|
||||
);
|
||||
const isBeamAwaitingPreview = !!beamViewer && !modelViewerUrl;
|
||||
const isBeamModel = isBeamModelFile(fullFileName);
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [isModelViewerOpen, setIsModelViewerOpen] = useState(false);
|
||||
const [isThumbnailError, setIsThumbnailError] = useState(false);
|
||||
const menuItems: TContextMenuItem[] = [
|
||||
...(modelViewerUrl
|
||||
? [
|
||||
{
|
||||
key: "view-model",
|
||||
action: () => {
|
||||
setIsModelViewerOpen(true);
|
||||
},
|
||||
title: "Посмотреть модель",
|
||||
icon: Eye,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(modelDownloadUrl
|
||||
? [
|
||||
{
|
||||
key: "download-model-original",
|
||||
action: () => {
|
||||
window.open(modelDownloadUrl, "_blank", "noopener,noreferrer");
|
||||
},
|
||||
title: "Скачать оригинал",
|
||||
icon: Download,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "delete",
|
||||
action: () => {
|
||||
@@ -88,6 +147,34 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
useEffect(() => {
|
||||
if (!beamViewer || beamViewer.viewerUrl || !beamViewer.conversion) return;
|
||||
|
||||
let isMounted = true;
|
||||
let timeoutId: ReturnType<typeof window.setTimeout> | undefined;
|
||||
|
||||
const pollStatus = async () => {
|
||||
try {
|
||||
const status = await fetchBeamConversionStatus(beamViewer);
|
||||
if (!isMounted) return;
|
||||
setBeamConversionStatus(status);
|
||||
if (status.status !== "ready" && status.status !== "failed") {
|
||||
timeoutId = window.setTimeout(pollStatus, 5000);
|
||||
}
|
||||
} catch (_error) {
|
||||
if (!isMounted) return;
|
||||
timeoutId = window.setTimeout(pollStatus, 8000);
|
||||
}
|
||||
};
|
||||
|
||||
pollStatus();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (timeoutId) window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [beamViewer]);
|
||||
|
||||
if (!attachment) return <></>;
|
||||
|
||||
return (
|
||||
@@ -99,7 +186,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsPreviewOpen(true);
|
||||
if (modelViewerUrl) setIsModelViewerOpen(true);
|
||||
else setIsPreviewOpen(true);
|
||||
}}
|
||||
>
|
||||
<div className="relative h-full w-28 flex-shrink-0 overflow-hidden bg-surface-1">
|
||||
@@ -122,7 +210,13 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
<IssueAttachmentPdfThumbnail fileURL={previewURL} />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
{previewType === "file" ? fileIcon : <ImageIcon className="size-8 text-tertiary" />}
|
||||
{isBeamModel ? (
|
||||
<Box className="size-9 text-[rgb(var(--nodedc-accent-rgb))]" />
|
||||
) : previewType === "file" ? (
|
||||
fileIcon
|
||||
) : (
|
||||
<ImageIcon className="size-8 text-tertiary" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -137,6 +231,11 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
<span className="size-1 rounded-full bg-layer-1" />
|
||||
<span>{convertBytesToSize(attachment.attributes.size)}</span>
|
||||
</div>
|
||||
{beamConversionStatusLabel && (
|
||||
<div className="mt-2 w-fit whitespace-nowrap rounded-full bg-[rgba(var(--nodedc-accent-rgb),0.12)] px-2 py-0.5 text-10 font-semibold text-[rgb(var(--nodedc-accent-rgb))]">
|
||||
{beamConversionStatusLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{attachment?.created_by && (
|
||||
@@ -173,9 +272,9 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
>
|
||||
<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 && (
|
||||
{previewDownloadUrl && (
|
||||
<a
|
||||
href={fileURL}
|
||||
href={previewDownloadUrl}
|
||||
download={fullFileName}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@@ -212,6 +311,32 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
</div>
|
||||
) : previewType === "pdf" && previewURL ? (
|
||||
<IssueAttachmentPdfPreview fileURL={previewURL} />
|
||||
) : isBeamAwaitingPreview ? (
|
||||
<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-[rgba(var(--nodedc-accent-rgb),0.12)] text-[rgb(var(--nodedc-accent-rgb))]">
|
||||
<Box className="size-10" />
|
||||
</div>
|
||||
<div className="max-w-md">
|
||||
<div className="text-15 font-semibold text-primary">
|
||||
{beamConversionStatusLabel ?? "Ожидает дерево"}
|
||||
</div>
|
||||
<div className="mt-2 text-14 text-secondary">
|
||||
Оригинальный STEP загружен в Beam. Preview появится после подготовки GLB/XKT и дерева компонентов.
|
||||
</div>
|
||||
</div>
|
||||
{modelDownloadUrl && (
|
||||
<a
|
||||
href={modelDownloadUrl}
|
||||
download={fullFileName}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[rgb(var(--nodedc-accent-rgb))] px-4 py-2 text-13 font-semibold text-[rgb(var(--nodedc-on-accent-rgb))] transition hover:brightness-110"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Скачать оригинал
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
@@ -223,6 +348,54 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
|
||||
<ModalCore
|
||||
isOpen={isModelViewerOpen && !!modelViewerUrl}
|
||||
handleClose={() => setIsModelViewerOpen(false)}
|
||||
position={EModalPosition.CENTER}
|
||||
width={EModalWidth.VIIXL}
|
||||
className="nodedc-beam-viewer-modal h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] overflow-hidden !border-0 !bg-black !shadow-none !ring-0 !outline-none focus:!outline-none"
|
||||
>
|
||||
<div className="relative h-full w-full bg-black">
|
||||
<div className="pointer-events-none absolute top-4 right-4 left-4 z-10 grid grid-cols-[1fr_auto_1fr] items-start gap-3">
|
||||
<div aria-hidden="true" />
|
||||
<div className="min-w-0 max-w-[min(60vw,36rem)] text-center text-white drop-shadow-[0_2px_10px_rgba(0,0,0,0.75)]">
|
||||
<div className="truncate text-13 font-semibold">{fullFileName}</div>
|
||||
<div className="mt-0.5 text-11 text-white/70">{convertBytesToSize(attachment.attributes.size)}</div>
|
||||
</div>
|
||||
<div className="pointer-events-auto flex items-center justify-end gap-2">
|
||||
{modelDownloadUrl && (
|
||||
<a
|
||||
href={modelDownloadUrl}
|
||||
download={fullFileName}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="grid size-10 place-items-center rounded-full bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))] shadow-[0_14px_30px_rgba(0,0,0,0.3)] transition hover:brightness-110"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Download className="size-5" />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-10 place-items-center rounded-full bg-black/45 text-white backdrop-blur-md transition hover:bg-black/60"
|
||||
onClick={() => setIsModelViewerOpen(false)}
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{modelViewerUrl && (
|
||||
<iframe
|
||||
src={modelViewerUrl}
|
||||
title={`Beam Viewer: ${fullFileName}`}
|
||||
tabIndex={-1}
|
||||
className="h-full w-full border-0 outline-none ring-0 focus:outline-none focus:ring-0"
|
||||
allow="fullscreen"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ModalCore>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,12 +8,29 @@ import type { AxiosRequestConfig } from "axios";
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
// plane types
|
||||
import { getFileMetaDataForUpload, generateFileUploadPayload } from "@plane/services";
|
||||
import type { TIssueAttachment, TIssueAttachmentUploadResponse, TIssueServiceType } from "@plane/types";
|
||||
import type {
|
||||
TFileMetaDataLite,
|
||||
TIssueAttachment,
|
||||
TIssueAttachmentUploadResponse,
|
||||
TIssueServiceType,
|
||||
} from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// services
|
||||
import {
|
||||
getBeamModelMimeType,
|
||||
isBeamModelFile,
|
||||
type TBeamViewerAttachment,
|
||||
uploadBeamModelFile,
|
||||
} from "@/helpers/beam-viewer";
|
||||
import { APIService } from "@/services/api.service";
|
||||
import { FileUploadService } from "@/services/file-upload.service";
|
||||
|
||||
type TIssueAttachmentCreateResponse = {
|
||||
asset_id: string;
|
||||
asset_url: string;
|
||||
attachment: TIssueAttachment;
|
||||
};
|
||||
|
||||
export class IssueAttachmentService extends APIService {
|
||||
private fileUploadService: FileUploadService;
|
||||
private serviceType: TIssueServiceType;
|
||||
@@ -40,6 +57,25 @@ export class IssueAttachmentService extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
private async createBeamIssueAttachmentReference(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
payload: TFileMetaDataLite & { beamViewer: TBeamViewerAttachment }
|
||||
): Promise<TIssueAttachment> {
|
||||
return this.post(
|
||||
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/`,
|
||||
payload
|
||||
)
|
||||
.then((response) => {
|
||||
const createResponse: TIssueAttachmentCreateResponse = response?.data;
|
||||
return createResponse.attachment;
|
||||
})
|
||||
.catch((error) => {
|
||||
throw error?.response?.data ?? error;
|
||||
});
|
||||
}
|
||||
|
||||
async uploadIssueAttachment(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
@@ -47,6 +83,22 @@ export class IssueAttachmentService extends APIService {
|
||||
file: File,
|
||||
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
|
||||
): Promise<TIssueAttachment> {
|
||||
if (isBeamModelFile(file.name)) {
|
||||
const beamViewer = await uploadBeamModelFile(file, {
|
||||
issueId,
|
||||
onUploadProgress: uploadProgressHandler,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
});
|
||||
|
||||
return this.createBeamIssueAttachmentReference(workspaceSlug, projectId, issueId, {
|
||||
beamViewer,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: getBeamModelMimeType(file),
|
||||
});
|
||||
}
|
||||
|
||||
const fileMetaData = await getFileMetaDataForUpload(file);
|
||||
return this.post(
|
||||
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/`,
|
||||
|
||||
Reference in New Issue
Block a user