beam: persist ready viewer attachments

This commit is contained in:
DCCONSTRUCTIONS 2026-06-05 01:36:34 +03:00
parent 677916389f
commit 31dca29560
5 changed files with 111 additions and 6 deletions

View File

@ -257,6 +257,25 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER]) @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def patch(self, request, slug, project_id, issue_id, pk): def patch(self, request, slug, project_id, issue_id, pk):
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) 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) serializer = IssueAttachmentSerializer(issue_attachment)
if not attachment_object_exists(issue_attachment): if not attachment_object_exists(issue_attachment):
return Response( return Response(

View File

@ -189,7 +189,10 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
key={attachmentId} key={attachmentId}
attachmentId={attachmentId} attachmentId={attachmentId}
disabled={disabled} disabled={disabled}
issueId={issueId}
issueServiceType={issueServiceType} issueServiceType={issueServiceType}
projectId={projectId}
workspaceSlug={workspaceSlug}
/> />
))} ))}
</div> </div>

View File

@ -5,7 +5,7 @@
*/ */
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { getIconButtonStyling } from "@plane/propel/icon-button"; import { getIconButtonStyling } from "@plane/propel/icon-button";
@ -27,6 +27,7 @@ import {
fetchBeamConversionStatus, fetchBeamConversionStatus,
getBeamViewerAttachment, getBeamViewerAttachment,
isBeamModelFile, isBeamModelFile,
type TBeamViewerAttachment,
type TBeamConversionStatus, type TBeamConversionStatus,
} from "@/helpers/beam-viewer"; } from "@/helpers/beam-viewer";
import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attachment-pdf-preview"; 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 { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member"; import { useMember } from "@/hooks/store/use-member";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
import { IssueAttachmentService } from "@/services/issue";
type TIssueAttachmentsListItem = { type TIssueAttachmentsListItem = {
attachmentId: string; attachmentId: string;
disabled?: boolean; disabled?: boolean;
issueServiceType?: TIssueServiceType; issueServiceType?: TIssueServiceType;
issueId: string;
projectId: string;
workspaceSlug: string;
}; };
const IMAGE_EXTENSIONS = new Set(["apng", "avif", "bmp", "gif", "jpg", "jpeg", "png", "svg", "webp"]); 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}` : ""}`; 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 getPreviewType = (extension: string) => {
const normalizedExtension = extension.toLowerCase(); const normalizedExtension = extension.toLowerCase();
if (IMAGE_EXTENSIONS.has(normalizedExtension)) return "image"; if (IMAGE_EXTENSIONS.has(normalizedExtension)) return "image";
@ -69,14 +86,49 @@ const getBeamConversionStatusLabel = (status: string | undefined): string | null
return "Ожидает дерево"; 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) { export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) {
const { t } = useTranslation(); const { t } = useTranslation();
// props // props
const { attachmentId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props; const { attachmentId, disabled, issueId, issueServiceType = EIssueServiceType.ISSUES, projectId, workspaceSlug } = props;
// store hooks // store hooks
const { getUserDetails } = useMember(); const { getUserDetails } = useMember();
const { const {
attachment: { getAttachmentById }, attachment: { getAttachmentById },
fetchAttachments,
toggleDeleteAttachmentModal, toggleDeleteAttachmentModal,
} = useIssueDetail(issueServiceType); } = useIssueDetail(issueServiceType);
// derived values // derived values
@ -91,16 +143,17 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
const beamViewer = getBeamViewerAttachment(attachment); const beamViewer = getBeamViewerAttachment(attachment);
const [beamConversionStatus, setBeamConversionStatus] = useState<TBeamConversionStatus | null>(null); const [beamConversionStatus, setBeamConversionStatus] = useState<TBeamConversionStatus | null>(null);
const [beamConversionError, setBeamConversionError] = useState<string | null>(null); const [beamConversionError, setBeamConversionError] = useState<string | null>(null);
const attachmentService = useMemo(() => new IssueAttachmentService(issueServiceType), [issueServiceType]);
const resolvedArtifactUrl = beamConversionStatus?.status === "ready" ? beamConversionStatus.artifactUrl : undefined; const resolvedArtifactUrl = beamConversionStatus?.status === "ready" ? beamConversionStatus.artifactUrl : undefined;
const storedModelViewerUrl = getUsableBeamViewerUrl(beamViewer?.viewerUrl);
const modelViewerUrl = const modelViewerUrl =
beamViewer?.viewerUrl ||
(resolvedArtifactUrl (resolvedArtifactUrl
? buildBeamViewerUrl({ ? buildBeamViewerUrl({
name: fullFileName, name: fullFileName,
src: resolvedArtifactUrl, src: resolvedArtifactUrl,
type: beamConversionStatus?.artifactType || "gltf", type: beamConversionStatus?.artifactType || "gltf",
}) })
: undefined); : undefined) || storedModelViewerUrl;
const modelDownloadUrl = beamViewer?.downloadUrl || beamViewer?.src; const modelDownloadUrl = beamViewer?.downloadUrl || beamViewer?.src;
const previewDownloadUrl = modelDownloadUrl || fileURL; const previewDownloadUrl = modelDownloadUrl || fileURL;
const isBeamModel = isBeamModelFile(fullFileName); const isBeamModel = isBeamModelFile(fullFileName);
@ -160,7 +213,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
useEffect(() => { useEffect(() => {
if (!beamViewer || beamViewer.viewerUrl || !beamViewer.src) return; if (!beamViewer || storedModelViewerUrl || !beamViewer.src) return;
let isMounted = true; let isMounted = true;
let timeoutId: number | undefined; let timeoutId: number | undefined;
@ -171,6 +224,13 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
if (!isMounted) return; if (!isMounted) return;
setBeamConversionError(null); setBeamConversionError(null);
setBeamConversionStatus(status); 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") { if (status.status !== "ready" && status.status !== "failed") {
timeoutId = window.setTimeout(pollStatus, 5000); timeoutId = window.setTimeout(pollStatus, 5000);
} }
@ -187,7 +247,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
isMounted = false; isMounted = false;
if (timeoutId) window.clearTimeout(timeoutId); if (timeoutId) window.clearTimeout(timeoutId);
}; };
}, [beamViewer]); }, [attachmentId, attachmentService, beamViewer, fetchAttachments, fullFileName, issueId, projectId, storedModelViewerUrl, workspaceSlug]);
if (!attachment) return <></>; if (!attachment) return <></>;
@ -430,6 +490,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
</div> </div>
{modelViewerUrl && ( {modelViewerUrl && (
<iframe <iframe
key={modelViewerUrl}
src={modelViewerUrl} src={modelViewerUrl}
title={`Beam Viewer: ${fullFileName}`} title={`Beam Viewer: ${fullFileName}`}
tabIndex={-1} tabIndex={-1}

View File

@ -76,6 +76,23 @@ export class IssueAttachmentService extends APIService {
}); });
} }
async updateBeamIssueAttachmentReference(
workspaceSlug: string,
projectId: string,
issueId: string,
attachmentId: string,
beamViewer: TBeamViewerAttachment
): Promise<TIssueAttachment> {
return this.patch(
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/${attachmentId}/`,
{ beamViewer }
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data ?? error;
});
}
async uploadIssueAttachment( async uploadIssueAttachment(
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,

View File

@ -4,11 +4,16 @@ import type { TIssueAttachment } from "@plane/types";
export type TBeamViewerAttachment = { export type TBeamViewerAttachment = {
backend: "beam-viewer-local" | "beam-viewer"; backend: "beam-viewer-local" | "beam-viewer";
conversion?: { conversion?: {
artifactSrc?: string;
artifactType?: string;
componentTreeRequired: boolean; componentTreeRequired: boolean;
message?: string; message?: string;
metadataSrc?: string;
sourceFormat: string; sourceFormat: string;
sourceSrc?: string;
status: "conversion_required" | "processing" | "ready" | "failed"; status: "conversion_required" | "processing" | "ready" | "failed";
targetFormat: "glb" | "xkt"; targetFormat: "glb" | "xkt";
updatedAt?: string;
}; };
downloadUrl: string; downloadUrl: string;
originalFilename: string; originalFilename: string;