beam: integrate model attachments with Beam viewer

This commit is contained in:
DCCONSTRUCTIONS
2026-06-04 19:39:36 +03:00
parent 6e74964853
commit 3e41c451c1
9 changed files with 583 additions and 7 deletions
@@ -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/`,