From 3e41c451c1a19231c3f104b82615c90f85687fe5 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Thu, 4 Jun 2026 19:39:36 +0300 Subject: [PATCH] beam: integrate model attachments with Beam viewer --- plane-app/reload-web-local-light.sh | 2 + .../api/plane/app/views/issue/attachment.py | 45 ++++ plane-src/apps/web/Dockerfile.web | 6 + plane-src/apps/web/Dockerfile.web.nas-legacy | 6 + .../attachment/attachment-list-item.tsx | 185 ++++++++++++- .../issue/issue_attachment.service.ts | 54 +++- plane-src/apps/web/helpers/beam-viewer.ts | 244 ++++++++++++++++++ plane-src/apps/web/styles/globals.css | 27 ++ .../types/src/issues/issue_attachment.ts | 21 ++ 9 files changed, 583 insertions(+), 7 deletions(-) create mode 100644 plane-src/apps/web/helpers/beam-viewer.ts diff --git a/plane-app/reload-web-local-light.sh b/plane-app/reload-web-local-light.sh index dbcb126..d1632e1 100755 --- a/plane-app/reload-web-local-light.sh +++ b/plane-app/reload-web-local-light.sh @@ -16,6 +16,8 @@ VITE_SPACE_BASE_PATH="${VITE_SPACE_BASE_PATH:-/spaces}" \ VITE_LIVE_BASE_URL="${VITE_LIVE_BASE_URL-}" \ VITE_LIVE_BASE_PATH="${VITE_LIVE_BASE_PATH:-/live}" \ VITE_WEB_BASE_URL="${VITE_WEB_BASE_URL-}" \ +VITE_BEAM_VIEWER_BASE_URL="${VITE_BEAM_VIEWER_BASE_URL:-http://localhost:8080}" \ +VITE_BEAM_API_BASE_URL="${VITE_BEAM_API_BASE_URL:-http://localhost:8080}" \ VITE_NODEDC_LAUNCHER_URL="${VITE_NODEDC_LAUNCHER_URL:-http://launcher.local.nodedc}" \ VITE_NODEDC_OIDC_LOGIN_ENABLED="${VITE_NODEDC_OIDC_LOGIN_ENABLED:-1}" \ pnpm --filter web build diff --git a/plane-src/apps/api/plane/app/views/issue/attachment.py b/plane-src/apps/api/plane/app/views/issue/attachment.py index cb48645..cd5d93a 100644 --- a/plane-src/apps/api/plane/app/views/issue/attachment.py +++ b/plane-src/apps/api/plane/app/views/issue/attachment.py @@ -109,6 +109,12 @@ class IssueAttachmentV2Endpoint(BaseAPIView): name = request.data.get("name") type = request.data.get("type", False) size = int(request.data.get("size", settings.FILE_SIZE_LIMIT)) + beam_viewer = request.data.get("beamViewer") + is_beam_viewer_reference = ( + isinstance(beam_viewer, dict) + and beam_viewer.get("src") + and beam_viewer.get("downloadUrl") + ) if not type or type not in settings.ATTACHMENT_MIME_TYPES: return Response( @@ -120,6 +126,45 @@ class IssueAttachmentV2Endpoint(BaseAPIView): workspace = Workspace.objects.get(slug=slug) project = Project.objects.get(id=project_id, workspace=workspace) + if is_beam_viewer_reference: + asset = FileAsset.objects.create( + attributes={ + "name": name, + "type": type, + "size": size, + "beamViewer": beam_viewer, + }, + asset=f"{workspace.id}/beam-viewer/{uuid.uuid4().hex}-{name}", + size=0, + workspace_id=workspace.id, + created_by=request.user, + issue_id=issue_id, + project_id=project_id, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + is_uploaded=True, + external_source="beam-viewer", + ) + serializer = IssueAttachmentSerializer(asset) + issue_activity.delay( + type="attachment.activity.created", + requested_data=None, + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("issue_id", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + return Response( + { + "asset_id": str(asset.id), + "attachment": serializer.data, + "asset_url": asset.asset_url, + }, + status=status.HTTP_200_OK, + ) + # asset key asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" diff --git a/plane-src/apps/web/Dockerfile.web b/plane-src/apps/web/Dockerfile.web index 9d6d80f..33b16a1 100644 --- a/plane-src/apps/web/Dockerfile.web +++ b/plane-src/apps/web/Dockerfile.web @@ -67,6 +67,12 @@ ENV VITE_SPACE_BASE_PATH=$VITE_SPACE_BASE_PATH ARG VITE_WEB_BASE_URL="" ENV VITE_WEB_BASE_URL=$VITE_WEB_BASE_URL +ARG VITE_BEAM_VIEWER_BASE_URL="" +ENV VITE_BEAM_VIEWER_BASE_URL=$VITE_BEAM_VIEWER_BASE_URL + +ARG VITE_BEAM_API_BASE_URL="" +ENV VITE_BEAM_API_BASE_URL=$VITE_BEAM_API_BASE_URL + ARG VITE_NODEDC_OIDC_LOGIN_ENABLED="" ENV VITE_NODEDC_OIDC_LOGIN_ENABLED=$VITE_NODEDC_OIDC_LOGIN_ENABLED diff --git a/plane-src/apps/web/Dockerfile.web.nas-legacy b/plane-src/apps/web/Dockerfile.web.nas-legacy index 36a90b5..982e63c 100644 --- a/plane-src/apps/web/Dockerfile.web.nas-legacy +++ b/plane-src/apps/web/Dockerfile.web.nas-legacy @@ -66,6 +66,12 @@ ENV VITE_SPACE_BASE_PATH=$VITE_SPACE_BASE_PATH ARG VITE_WEB_BASE_URL="" ENV VITE_WEB_BASE_URL=$VITE_WEB_BASE_URL +ARG VITE_BEAM_VIEWER_BASE_URL="" +ENV VITE_BEAM_VIEWER_BASE_URL=$VITE_BEAM_VIEWER_BASE_URL + +ARG VITE_BEAM_API_BASE_URL="" +ENV VITE_BEAM_API_BASE_URL=$VITE_BEAM_API_BASE_URL + ARG VITE_NODEDC_OIDC_LOGIN_ENABLED="" ENV VITE_NODEDC_OIDC_LOGIN_ENABLED=$VITE_NODEDC_OIDC_LOGIN_ENABLED diff --git a/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx b/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx index 3968287..8037832 100644 --- a/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx +++ b/plane-src/apps/web/core/components/issues/attachment/attachment-list-item.tsx @@ -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(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 | 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); }} >
@@ -122,7 +210,13 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt ) : (
- {previewType === "file" ? fileIcon : } + {isBeamModel ? ( + + ) : previewType === "file" ? ( + fileIcon + ) : ( + + )}
)}
@@ -137,6 +231,11 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt {convertBytesToSize(attachment.attributes.size)} + {beamConversionStatusLabel && ( +
+ {beamConversionStatusLabel} +
+ )} {attachment?.created_by && ( @@ -173,9 +272,9 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt >
+ + 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" + > +
+
+ + {modelViewerUrl && ( +