beam: integrate model attachments with Beam viewer
This commit is contained in:
parent
6e74964853
commit
3e41c451c1
|
|
@ -16,6 +16,8 @@ VITE_SPACE_BASE_PATH="${VITE_SPACE_BASE_PATH:-/spaces}" \
|
||||||
VITE_LIVE_BASE_URL="${VITE_LIVE_BASE_URL-}" \
|
VITE_LIVE_BASE_URL="${VITE_LIVE_BASE_URL-}" \
|
||||||
VITE_LIVE_BASE_PATH="${VITE_LIVE_BASE_PATH:-/live}" \
|
VITE_LIVE_BASE_PATH="${VITE_LIVE_BASE_PATH:-/live}" \
|
||||||
VITE_WEB_BASE_URL="${VITE_WEB_BASE_URL-}" \
|
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_LAUNCHER_URL="${VITE_NODEDC_LAUNCHER_URL:-http://launcher.local.nodedc}" \
|
||||||
VITE_NODEDC_OIDC_LOGIN_ENABLED="${VITE_NODEDC_OIDC_LOGIN_ENABLED:-1}" \
|
VITE_NODEDC_OIDC_LOGIN_ENABLED="${VITE_NODEDC_OIDC_LOGIN_ENABLED:-1}" \
|
||||||
pnpm --filter web build
|
pnpm --filter web build
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,12 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
||||||
name = request.data.get("name")
|
name = request.data.get("name")
|
||||||
type = request.data.get("type", False)
|
type = request.data.get("type", False)
|
||||||
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
|
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:
|
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
|
||||||
return Response(
|
return Response(
|
||||||
|
|
@ -120,6 +126,45 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
|
||||||
workspace = Workspace.objects.get(slug=slug)
|
workspace = Workspace.objects.get(slug=slug)
|
||||||
project = Project.objects.get(id=project_id, workspace=workspace)
|
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
|
||||||
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,12 @@ ENV VITE_SPACE_BASE_PATH=$VITE_SPACE_BASE_PATH
|
||||||
ARG VITE_WEB_BASE_URL=""
|
ARG VITE_WEB_BASE_URL=""
|
||||||
ENV VITE_WEB_BASE_URL=$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=""
|
ARG VITE_NODEDC_OIDC_LOGIN_ENABLED=""
|
||||||
ENV VITE_NODEDC_OIDC_LOGIN_ENABLED=$VITE_NODEDC_OIDC_LOGIN_ENABLED
|
ENV VITE_NODEDC_OIDC_LOGIN_ENABLED=$VITE_NODEDC_OIDC_LOGIN_ENABLED
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,12 @@ ENV VITE_SPACE_BASE_PATH=$VITE_SPACE_BASE_PATH
|
||||||
ARG VITE_WEB_BASE_URL=""
|
ARG VITE_WEB_BASE_URL=""
|
||||||
ENV VITE_WEB_BASE_URL=$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=""
|
ARG VITE_NODEDC_OIDC_LOGIN_ENABLED=""
|
||||||
ENV VITE_NODEDC_OIDC_LOGIN_ENABLED=$VITE_NODEDC_OIDC_LOGIN_ENABLED
|
ENV VITE_NODEDC_OIDC_LOGIN_ENABLED=$VITE_NODEDC_OIDC_LOGIN_ENABLED
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { useState } from "react";
|
import { useEffect, 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";
|
||||||
|
|
@ -17,11 +17,18 @@ import { EIssueServiceType } from "@plane/types";
|
||||||
import type { TContextMenuItem } from "@plane/ui";
|
import type { TContextMenuItem } from "@plane/ui";
|
||||||
import { ActionDropdown, EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
import { ActionDropdown, EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||||
import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils";
|
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
|
// components
|
||||||
//
|
//
|
||||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||||
import { getFileIcon } from "@/components/icons";
|
import { getFileIcon } from "@/components/icons";
|
||||||
|
import {
|
||||||
|
buildBeamViewerUrl,
|
||||||
|
fetchBeamConversionStatus,
|
||||||
|
getBeamViewerAttachment,
|
||||||
|
isBeamModelFile,
|
||||||
|
type TBeamConversionStatus,
|
||||||
|
} from "@/helpers/beam-viewer";
|
||||||
import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attachment-pdf-preview";
|
import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attachment-pdf-preview";
|
||||||
// helpers
|
// helpers
|
||||||
// hooks
|
// hooks
|
||||||
|
|
@ -54,6 +61,14 @@ const getPreviewType = (extension: string) => {
|
||||||
return "file";
|
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) {
|
export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// props
|
// props
|
||||||
|
|
@ -73,9 +88,53 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
const fileIcon = getFileIcon(fileExtension, 32);
|
const fileIcon = getFileIcon(fileExtension, 32);
|
||||||
const fileURL = getFileURL(attachment?.asset_url ?? "");
|
const fileURL = getFileURL(attachment?.asset_url ?? "");
|
||||||
const previewURL = appendSearchParam(fileURL, "preview", "true");
|
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 [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||||
|
const [isModelViewerOpen, setIsModelViewerOpen] = useState(false);
|
||||||
const [isThumbnailError, setIsThumbnailError] = useState(false);
|
const [isThumbnailError, setIsThumbnailError] = useState(false);
|
||||||
const menuItems: TContextMenuItem[] = [
|
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",
|
key: "delete",
|
||||||
action: () => {
|
action: () => {
|
||||||
|
|
@ -88,6 +147,34 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
// hooks
|
// hooks
|
||||||
const { isMobile } = usePlatformOS();
|
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 <></>;
|
if (!attachment) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -99,7 +186,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
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">
|
<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} />
|
<IssueAttachmentPdfThumbnail fileURL={previewURL} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -137,6 +231,11 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
<span className="size-1 rounded-full bg-layer-1" />
|
<span className="size-1 rounded-full bg-layer-1" />
|
||||||
<span>{convertBytesToSize(attachment.attributes.size)}</span>
|
<span>{convertBytesToSize(attachment.attributes.size)}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{attachment?.created_by && (
|
{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="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">
|
<div className="absolute top-4 right-4 z-10 flex items-center gap-2">
|
||||||
{fileURL && (
|
{previewDownloadUrl && (
|
||||||
<a
|
<a
|
||||||
href={fileURL}
|
href={previewDownloadUrl}
|
||||||
download={fullFileName}
|
download={fullFileName}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
|
@ -212,6 +311,32 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||||
</div>
|
</div>
|
||||||
) : previewType === "pdf" && previewURL ? (
|
) : previewType === "pdf" && previewURL ? (
|
||||||
<IssueAttachmentPdfPreview fileURL={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="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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</ModalCore>
|
</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";
|
import { API_BASE_URL } from "@plane/constants";
|
||||||
// plane types
|
// plane types
|
||||||
import { getFileMetaDataForUpload, generateFileUploadPayload } from "@plane/services";
|
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";
|
import { EIssueServiceType } from "@plane/types";
|
||||||
// services
|
// services
|
||||||
|
import {
|
||||||
|
getBeamModelMimeType,
|
||||||
|
isBeamModelFile,
|
||||||
|
type TBeamViewerAttachment,
|
||||||
|
uploadBeamModelFile,
|
||||||
|
} from "@/helpers/beam-viewer";
|
||||||
import { APIService } from "@/services/api.service";
|
import { APIService } from "@/services/api.service";
|
||||||
import { FileUploadService } from "@/services/file-upload.service";
|
import { FileUploadService } from "@/services/file-upload.service";
|
||||||
|
|
||||||
|
type TIssueAttachmentCreateResponse = {
|
||||||
|
asset_id: string;
|
||||||
|
asset_url: string;
|
||||||
|
attachment: TIssueAttachment;
|
||||||
|
};
|
||||||
|
|
||||||
export class IssueAttachmentService extends APIService {
|
export class IssueAttachmentService extends APIService {
|
||||||
private fileUploadService: FileUploadService;
|
private fileUploadService: FileUploadService;
|
||||||
private serviceType: TIssueServiceType;
|
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(
|
async uploadIssueAttachment(
|
||||||
workspaceSlug: string,
|
workspaceSlug: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
|
@ -47,6 +83,22 @@ export class IssueAttachmentService extends APIService {
|
||||||
file: File,
|
file: File,
|
||||||
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
|
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
|
||||||
): Promise<TIssueAttachment> {
|
): 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);
|
const fileMetaData = await getFileMetaDataForUpload(file);
|
||||||
return this.post(
|
return this.post(
|
||||||
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/`,
|
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/`,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,244 @@
|
||||||
|
import type { AxiosProgressEvent } from "axios";
|
||||||
|
import type { TIssueAttachment } from "@plane/types";
|
||||||
|
|
||||||
|
export type TBeamViewerAttachment = {
|
||||||
|
backend: "beam-viewer-local" | "beam-viewer";
|
||||||
|
conversion?: {
|
||||||
|
componentTreeRequired: boolean;
|
||||||
|
message?: string;
|
||||||
|
sourceFormat: string;
|
||||||
|
status: "conversion_required" | "processing" | "ready" | "failed";
|
||||||
|
targetFormat: "glb" | "xkt";
|
||||||
|
};
|
||||||
|
downloadUrl: string;
|
||||||
|
originalFilename: string;
|
||||||
|
previewAvailable: boolean;
|
||||||
|
src: string;
|
||||||
|
type: string;
|
||||||
|
uploadedAt: string;
|
||||||
|
viewerUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TBeamConversionStatus = {
|
||||||
|
artifactSrc?: string;
|
||||||
|
artifactType?: string;
|
||||||
|
artifactUrl?: string;
|
||||||
|
componentCount?: number;
|
||||||
|
componentTreeRequired?: boolean;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
metadataSrc?: string;
|
||||||
|
metadataUrl?: string;
|
||||||
|
sourceFormat?: string;
|
||||||
|
sourceSrc?: string;
|
||||||
|
status: "conversion_required" | "processing" | "ready" | "failed";
|
||||||
|
targetFormat?: "glb" | "xkt";
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_BEAM_VIEWER_BASE_URL = "http://localhost:8080";
|
||||||
|
|
||||||
|
const BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||||
|
bim: "bim",
|
||||||
|
glb: "gltf",
|
||||||
|
gltf: "gltf",
|
||||||
|
las: "las",
|
||||||
|
laz: "las",
|
||||||
|
obj: "obj",
|
||||||
|
stl: "stl",
|
||||||
|
xkt: "xkt",
|
||||||
|
};
|
||||||
|
|
||||||
|
const BEAM_CONVERTIBLE_MODEL_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||||
|
step: "step",
|
||||||
|
stp: "step",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIME_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||||
|
glb: "model/gltf-binary",
|
||||||
|
gltf: "model/gltf+json",
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeBaseUrl = (url: string | undefined): string =>
|
||||||
|
(url && url.trim() ? url.trim() : DEFAULT_BEAM_VIEWER_BASE_URL).replace(/\/+$/, "");
|
||||||
|
|
||||||
|
const getFileExtension = (name: string): string => {
|
||||||
|
const parts = name.split(".");
|
||||||
|
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUploadGroupId = (parts: Array<string | undefined>): string => {
|
||||||
|
const value = parts.filter(Boolean).join("_").replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||||
|
return value || "tasker";
|
||||||
|
};
|
||||||
|
|
||||||
|
const toBeamRelativeUploadSrc = (src: string): string => {
|
||||||
|
try {
|
||||||
|
const url = new URL(src);
|
||||||
|
return url.pathname.replace(/^\/+/, "");
|
||||||
|
} catch (_error) {
|
||||||
|
return src.replace(/^\/+/, "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toBeamAbsoluteUrl = (src: string | undefined, cacheKey?: string): string | undefined => {
|
||||||
|
if (!src) return undefined;
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(src);
|
||||||
|
} catch (_error) {
|
||||||
|
url = new URL(src.startsWith("/") ? src : `/${src}`, `${getBeamApiBaseUrl()}/`);
|
||||||
|
}
|
||||||
|
if (cacheKey) url.searchParams.set("v", cacheKey);
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBeamViewerBaseUrl = (): string => normalizeBaseUrl(process.env.VITE_BEAM_VIEWER_BASE_URL);
|
||||||
|
|
||||||
|
export const getBeamApiBaseUrl = (): string =>
|
||||||
|
normalizeBaseUrl(process.env.VITE_BEAM_API_BASE_URL || process.env.VITE_BEAM_VIEWER_BASE_URL);
|
||||||
|
|
||||||
|
export const getBeamModelTypeFromName = (name: string | undefined): string | null => {
|
||||||
|
if (!name) return null;
|
||||||
|
const extension = getFileExtension(name);
|
||||||
|
return BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION[extension] ?? BEAM_CONVERTIBLE_MODEL_TYPE_BY_EXTENSION[extension] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBeamDirectModelTypeFromName = (name: string | undefined): string | null => {
|
||||||
|
if (!name) return null;
|
||||||
|
return BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION[getFileExtension(name)] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBeamModelMimeType = (file: File): string =>
|
||||||
|
MIME_TYPE_BY_EXTENSION[getFileExtension(file.name)] || "application/octet-stream";
|
||||||
|
|
||||||
|
export const isBeamModelFile = (name: string | undefined): boolean => !!getBeamModelTypeFromName(name);
|
||||||
|
|
||||||
|
export const isBeamDirectViewerFile = (name: string | undefined): boolean => !!getBeamDirectModelTypeFromName(name);
|
||||||
|
|
||||||
|
export const buildBeamViewerUrl = (params: { name?: string; src: string; type?: string | null }): string => {
|
||||||
|
const viewerUrl = new URL(getBeamViewerBaseUrl());
|
||||||
|
viewerUrl.searchParams.set("url", params.src);
|
||||||
|
if (params.type) viewerUrl.searchParams.set("type", params.type);
|
||||||
|
if (params.type === "gltf" || params.type === "glb") viewerUrl.searchParams.set("edges", "false");
|
||||||
|
if (params.name) viewerUrl.searchParams.set("name", params.name);
|
||||||
|
return viewerUrl.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBeamViewerAttachment = (attachment: TIssueAttachment | undefined): TBeamViewerAttachment | null => {
|
||||||
|
const beamViewer = (attachment?.attributes as { beamViewer?: TBeamViewerAttachment } | undefined)?.beamViewer;
|
||||||
|
if (!beamViewer || typeof beamViewer !== "object") return null;
|
||||||
|
return beamViewer;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchBeamConversionStatus = async (
|
||||||
|
beamViewer: TBeamViewerAttachment
|
||||||
|
): Promise<TBeamConversionStatus> => {
|
||||||
|
const statusUrl = new URL("/api/conversions/status", `${getBeamApiBaseUrl()}/`);
|
||||||
|
statusUrl.searchParams.set("src", toBeamRelativeUploadSrc(beamViewer.src));
|
||||||
|
|
||||||
|
const response = await fetch(statusUrl.toString());
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Beam conversion status failed: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as TBeamConversionStatus;
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
artifactUrl: toBeamAbsoluteUrl(payload.artifactSrc, payload.updatedAt),
|
||||||
|
metadataUrl: toBeamAbsoluteUrl(payload.metadataSrc),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadBeamModelFile = (
|
||||||
|
file: File,
|
||||||
|
options: {
|
||||||
|
issueId?: string;
|
||||||
|
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
|
||||||
|
projectId?: string;
|
||||||
|
workspaceSlug?: string;
|
||||||
|
} = {}
|
||||||
|
): Promise<TBeamViewerAttachment> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const type = getBeamModelTypeFromName(file.name);
|
||||||
|
if (!type) {
|
||||||
|
reject(new Error("Формат модели не поддерживается Beam Viewer"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const directViewerType = getBeamDirectModelTypeFromName(file.name);
|
||||||
|
|
||||||
|
const apiBaseUrl = getBeamApiBaseUrl();
|
||||||
|
const uploadUrl = new URL("/api/uploads", `${apiBaseUrl}/`);
|
||||||
|
uploadUrl.searchParams.set("filename", file.name);
|
||||||
|
uploadUrl.searchParams.set("projectId", getUploadGroupId([options.workspaceSlug, options.projectId, options.issueId]));
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("POST", uploadUrl.toString());
|
||||||
|
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
if (!event.lengthComputable || !options.onUploadProgress) return;
|
||||||
|
options.onUploadProgress({
|
||||||
|
loaded: event.loaded,
|
||||||
|
progress: event.loaded / event.total,
|
||||||
|
total: event.total,
|
||||||
|
} as AxiosProgressEvent);
|
||||||
|
};
|
||||||
|
xhr.onerror = () => reject(new Error("Не удалось загрузить модель в Beam Viewer"));
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status < 200 || xhr.status >= 300) {
|
||||||
|
reject(new Error(xhr.responseText || `Beam Viewer upload failed: HTTP ${xhr.status}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(xhr.responseText) as {
|
||||||
|
conversion?: TBeamViewerAttachment["conversion"];
|
||||||
|
src?: string;
|
||||||
|
};
|
||||||
|
if (!response.src) {
|
||||||
|
reject(new Error("Beam Viewer не вернул путь к загруженной модели"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadUrl = new URL(response.src.startsWith("/") ? response.src : `/${response.src}`, `${apiBaseUrl}/`);
|
||||||
|
const baseAttachment: TBeamViewerAttachment = {
|
||||||
|
backend: "beam-viewer-local",
|
||||||
|
downloadUrl: downloadUrl.toString(),
|
||||||
|
originalFilename: file.name,
|
||||||
|
previewAvailable: !!directViewerType,
|
||||||
|
src: downloadUrl.toString(),
|
||||||
|
type,
|
||||||
|
uploadedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!directViewerType) {
|
||||||
|
resolve({
|
||||||
|
...baseAttachment,
|
||||||
|
conversion: {
|
||||||
|
...(response.conversion ?? {}),
|
||||||
|
componentTreeRequired: true,
|
||||||
|
message:
|
||||||
|
response.conversion?.message ??
|
||||||
|
"Оригинальный STEP загружен. Preview появится после подготовки GLB и дерева компонентов.",
|
||||||
|
sourceFormat: type,
|
||||||
|
status: response.conversion?.status ?? "conversion_required",
|
||||||
|
targetFormat: response.conversion?.targetFormat ?? "glb",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
...baseAttachment,
|
||||||
|
viewerUrl: buildBeamViewerUrl({
|
||||||
|
name: file.name,
|
||||||
|
src: downloadUrl.toString(),
|
||||||
|
type: directViewerType,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (_error) {
|
||||||
|
reject(new Error("Beam Viewer вернул некорректный ответ"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send(file);
|
||||||
|
});
|
||||||
|
|
@ -345,6 +345,33 @@
|
||||||
0 8px 22px rgba(0, 0, 0, 0.24);
|
0 8px 22px rgba(0, 0, 0, 0.24);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nodedc-beam-viewer-modal {
|
||||||
|
background: #000 !important;
|
||||||
|
border: 0 !important;
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
-webkit-backdrop-filter: none !important;
|
||||||
|
backdrop-filter: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-beam-viewer-modal::before,
|
||||||
|
.nodedc-beam-viewer-modal::after {
|
||||||
|
border: 0 !important;
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodedc-beam-viewer-modal:focus,
|
||||||
|
.nodedc-beam-viewer-modal:focus-visible,
|
||||||
|
.nodedc-beam-viewer-modal:focus-within,
|
||||||
|
.nodedc-beam-viewer-modal iframe,
|
||||||
|
.nodedc-beam-viewer-modal iframe:focus,
|
||||||
|
.nodedc-beam-viewer-modal iframe:focus-visible {
|
||||||
|
border: 0 !important;
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.nodedc-glass-surface {
|
.nodedc-glass-surface {
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.025) 0%, rgba(255, 255, 255, 0.01) 100%), rgba(9, 9, 12, 0.88);
|
linear-gradient(180deg, rgba(255, 255, 255, 0.025) 0%, rgba(255, 255, 255, 0.01) 100%), rgba(9, 9, 12, 0.88);
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,32 @@
|
||||||
|
|
||||||
import type { TFileSignedURLResponse } from "../file";
|
import type { TFileSignedURLResponse } from "../file";
|
||||||
|
|
||||||
|
export type TBeamViewerAttachment = {
|
||||||
|
backend: "beam-viewer-local" | "beam-viewer";
|
||||||
|
conversion?: {
|
||||||
|
componentTreeRequired: boolean;
|
||||||
|
message?: string;
|
||||||
|
sourceFormat: string;
|
||||||
|
status: "conversion_required" | "processing" | "ready" | "failed";
|
||||||
|
targetFormat: "glb" | "xkt";
|
||||||
|
};
|
||||||
|
downloadUrl: string;
|
||||||
|
originalFilename: string;
|
||||||
|
previewAvailable: boolean;
|
||||||
|
src: string;
|
||||||
|
type: string;
|
||||||
|
uploadedAt: string;
|
||||||
|
viewerUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type TIssueAttachment = {
|
export type TIssueAttachment = {
|
||||||
id: string;
|
id: string;
|
||||||
attributes: {
|
attributes: {
|
||||||
|
beamViewer?: TBeamViewerAttachment;
|
||||||
name: string;
|
name: string;
|
||||||
size: number;
|
size: number;
|
||||||
|
type?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
asset_url: string;
|
asset_url: string;
|
||||||
issue_id: string;
|
issue_id: string;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue