From 31dca29560f32b180761d1037c3da05274a1bbe1 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 5 Jun 2026 01:36:34 +0300 Subject: [PATCH] beam: persist ready viewer attachments --- .../api/plane/app/views/issue/attachment.py | 19 +++++ .../attachment/attachment-item-list.tsx | 3 + .../attachment/attachment-list-item.tsx | 73 +++++++++++++++++-- .../issue/issue_attachment.service.ts | 17 +++++ plane-src/apps/web/helpers/beam-viewer.ts | 5 ++ 5 files changed, 111 insertions(+), 6 deletions(-) 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 cd5d93a..546eb9a 100644 --- a/plane-src/apps/api/plane/app/views/issue/attachment.py +++ b/plane-src/apps/api/plane/app/views/issue/attachment.py @@ -257,6 +257,25 @@ class IssueAttachmentV2Endpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def patch(self, request, slug, project_id, issue_id, pk): issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) + beam_viewer = request.data.get("beamViewer") + if isinstance(beam_viewer, dict): + attributes = issue_attachment.attributes or {} + existing_beam_viewer = attributes.get("beamViewer") + if not isinstance(existing_beam_viewer, dict): + return Response( + {"error": "The attachment is not a Beam Viewer reference.", "status": False}, + status=status.HTTP_400_BAD_REQUEST, + ) + + attributes["beamViewer"] = { + **existing_beam_viewer, + **beam_viewer, + } + issue_attachment.attributes = attributes + issue_attachment.is_uploaded = True + issue_attachment.save(update_fields=["attributes", "is_uploaded", "updated_at"]) + return Response(IssueAttachmentSerializer(issue_attachment).data, status=status.HTTP_200_OK) + serializer = IssueAttachmentSerializer(issue_attachment) if not attachment_object_exists(issue_attachment): return Response( diff --git a/plane-src/apps/web/core/components/issues/attachment/attachment-item-list.tsx b/plane-src/apps/web/core/components/issues/attachment/attachment-item-list.tsx index b9b254f..6fc915f 100644 --- a/plane-src/apps/web/core/components/issues/attachment/attachment-item-list.tsx +++ b/plane-src/apps/web/core/components/issues/attachment/attachment-item-list.tsx @@ -189,7 +189,10 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList key={attachmentId} attachmentId={attachmentId} disabled={disabled} + issueId={issueId} issueServiceType={issueServiceType} + projectId={projectId} + workspaceSlug={workspaceSlug} /> ))} 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 dc000cc..645c816 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 { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "@plane/i18n"; import { getIconButtonStyling } from "@plane/propel/icon-button"; @@ -27,6 +27,7 @@ import { fetchBeamConversionStatus, getBeamViewerAttachment, isBeamModelFile, + type TBeamViewerAttachment, type TBeamConversionStatus, } from "@/helpers/beam-viewer"; import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attachment-pdf-preview"; @@ -35,11 +36,15 @@ import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attach import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useMember } from "@/hooks/store/use-member"; import { usePlatformOS } from "@/hooks/use-platform-os"; +import { IssueAttachmentService } from "@/services/issue"; type TIssueAttachmentsListItem = { attachmentId: string; disabled?: boolean; issueServiceType?: TIssueServiceType; + issueId: string; + projectId: string; + workspaceSlug: string; }; const IMAGE_EXTENSIONS = new Set(["apng", "avif", "bmp", "gif", "jpg", "jpeg", "png", "svg", "webp"]); @@ -53,6 +58,18 @@ const appendSearchParam = (url: string | undefined, key: string, value: string): return `${urlWithoutHash}${separator}${key}=${encodeURIComponent(value)}${hash ? `#${hash}` : ""}`; }; +const getUsableBeamViewerUrl = (url: string | undefined): string | undefined => { + if (!url) return undefined; + try { + const parsedUrl = new URL(url, "http://localhost"); + return parsedUrl.searchParams.has("url") || parsedUrl.searchParams.has("src") || parsedUrl.searchParams.has("projectId") + ? url + : undefined; + } catch (_error) { + return undefined; + } +}; + const getPreviewType = (extension: string) => { const normalizedExtension = extension.toLowerCase(); if (IMAGE_EXTENSIONS.has(normalizedExtension)) return "image"; @@ -69,14 +86,49 @@ const getBeamConversionStatusLabel = (status: string | undefined): string | null return "Ожидает дерево"; }; +const buildSyncedBeamViewerAttachment = ( + beamViewer: TBeamViewerAttachment, + status: TBeamConversionStatus, + fullFileName: string +): TBeamViewerAttachment | null => { + if (status.status !== "ready" || !status.artifactUrl) return null; + + const artifactType = status.artifactType || status.targetFormat || "gltf"; + const targetFormat: "glb" | "xkt" = status.targetFormat || (artifactType === "xkt" ? "xkt" : "glb"); + + return { + ...beamViewer, + previewAvailable: true, + viewerUrl: buildBeamViewerUrl({ + name: fullFileName, + src: status.artifactUrl, + type: artifactType, + }), + conversion: { + ...(beamViewer.conversion ?? {}), + artifactSrc: status.artifactSrc, + artifactType, + componentTreeRequired: status.componentTreeRequired ?? beamViewer.conversion?.componentTreeRequired ?? true, + message: status.message || beamViewer.conversion?.message, + metadataSrc: status.metadataSrc, + sourceFormat: status.sourceFormat || beamViewer.conversion?.sourceFormat || beamViewer.type, + sourceSrc: status.sourceSrc || beamViewer.conversion?.sourceSrc, + status: "ready", + targetFormat, + updatedAt: status.updatedAt || beamViewer.conversion?.updatedAt, + }, + }; +}; + export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) { const { t } = useTranslation(); // props - const { attachmentId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props; + const { attachmentId, disabled, issueId, issueServiceType = EIssueServiceType.ISSUES, projectId, workspaceSlug } = props; // store hooks const { getUserDetails } = useMember(); const { attachment: { getAttachmentById }, + fetchAttachments, toggleDeleteAttachmentModal, } = useIssueDetail(issueServiceType); // derived values @@ -91,16 +143,17 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt const beamViewer = getBeamViewerAttachment(attachment); const [beamConversionStatus, setBeamConversionStatus] = useState(null); const [beamConversionError, setBeamConversionError] = useState(null); + const attachmentService = useMemo(() => new IssueAttachmentService(issueServiceType), [issueServiceType]); const resolvedArtifactUrl = beamConversionStatus?.status === "ready" ? beamConversionStatus.artifactUrl : undefined; + const storedModelViewerUrl = getUsableBeamViewerUrl(beamViewer?.viewerUrl); const modelViewerUrl = - beamViewer?.viewerUrl || (resolvedArtifactUrl ? buildBeamViewerUrl({ name: fullFileName, src: resolvedArtifactUrl, type: beamConversionStatus?.artifactType || "gltf", }) - : undefined); + : undefined) || storedModelViewerUrl; const modelDownloadUrl = beamViewer?.downloadUrl || beamViewer?.src; const previewDownloadUrl = modelDownloadUrl || fileURL; const isBeamModel = isBeamModelFile(fullFileName); @@ -160,7 +213,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt const { isMobile } = usePlatformOS(); useEffect(() => { - if (!beamViewer || beamViewer.viewerUrl || !beamViewer.src) return; + if (!beamViewer || storedModelViewerUrl || !beamViewer.src) return; let isMounted = true; let timeoutId: number | undefined; @@ -171,6 +224,13 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt if (!isMounted) return; setBeamConversionError(null); setBeamConversionStatus(status); + const syncedBeamViewer = buildSyncedBeamViewerAttachment(beamViewer, status, fullFileName); + if (syncedBeamViewer && syncedBeamViewer.viewerUrl !== beamViewer.viewerUrl) { + attachmentService + .updateBeamIssueAttachmentReference(workspaceSlug, projectId, issueId, attachmentId, syncedBeamViewer) + .then(() => fetchAttachments(workspaceSlug, projectId, issueId)) + .catch((error) => console.error("Error in syncing Beam attachment metadata:", error)); + } if (status.status !== "ready" && status.status !== "failed") { timeoutId = window.setTimeout(pollStatus, 5000); } @@ -187,7 +247,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt isMounted = false; if (timeoutId) window.clearTimeout(timeoutId); }; - }, [beamViewer]); + }, [attachmentId, attachmentService, beamViewer, fetchAttachments, fullFileName, issueId, projectId, storedModelViewerUrl, workspaceSlug]); if (!attachment) return <>; @@ -430,6 +490,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt {modelViewerUrl && (