chore: checkpoint before cross assistant work
This commit is contained in:
@@ -211,8 +211,8 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
|
||||
</div>
|
||||
{isListView ? (
|
||||
<div className="nodedc-attachments-list-scroll overflow-auto rounded-2xl bg-surface-2/40">
|
||||
<div className="min-w-[760px]">
|
||||
<div className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_112px] gap-3 border-b border-subtle px-4 py-2 text-11 font-semibold tracking-[0.05em] text-tertiary uppercase">
|
||||
<div className="min-w-[840px]">
|
||||
<div className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_192px] gap-3 border-b border-subtle px-4 py-2 text-11 font-semibold tracking-[0.05em] text-tertiary uppercase">
|
||||
<div>Файл</div>
|
||||
<div>Дата</div>
|
||||
<div>Версия</div>
|
||||
@@ -222,7 +222,7 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
|
||||
{uploadStatus?.map((status) => (
|
||||
<div
|
||||
key={status.id}
|
||||
className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_112px] items-center gap-3 border-b border-subtle/70 px-4 py-3 text-12"
|
||||
className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_192px] items-center gap-3 border-b border-subtle/70 px-4 py-3 text-12"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-secondary">{status.name}</div>
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ChangeEvent, ReactNode } from "react";
|
||||
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { getIconButtonStyling } from "@plane/propel/icon-button";
|
||||
import { TrashIcon } from "@plane/propel/icons";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { TIssueServiceType } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
@@ -18,7 +19,7 @@ import { EIssueServiceType } from "@plane/types";
|
||||
import type { TContextMenuItem } from "@plane/ui";
|
||||
import { ActionDropdown, EModalPosition, EModalWidth, ModalCore, Spinner } from "@plane/ui";
|
||||
import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils";
|
||||
import { AlertTriangle, Box, Download, Eye, ImageIcon, Play, X } from "lucide-react";
|
||||
import { AlertTriangle, Box, Download, Eye, History, ImageIcon, Play, UploadCloud, X } from "lucide-react";
|
||||
// components
|
||||
//
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
@@ -27,8 +28,12 @@ import {
|
||||
buildBeamViewerUrl,
|
||||
dispatchBeamViewerOpenEvent,
|
||||
fetchBeamConversionStatus,
|
||||
getBeamModelVersionRecords,
|
||||
getBeamVersionViewerUrl,
|
||||
getBeamViewerAttachment,
|
||||
isBeamModelFile,
|
||||
syncCurrentBeamVersionRecord,
|
||||
type TBeamModelVersionRecord,
|
||||
type TBeamViewerAttachment,
|
||||
type TBeamConversionStatus,
|
||||
} from "@/helpers/beam-viewer";
|
||||
@@ -37,6 +42,7 @@ import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attach
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import { IssueAttachmentService } from "@/services/issue";
|
||||
|
||||
@@ -61,7 +67,7 @@ const appendSearchParam = (url: string | undefined, key: string, value: string):
|
||||
return `${urlWithoutHash}${separator}${key}=${encodeURIComponent(value)}${hash ? `#${hash}` : ""}`;
|
||||
};
|
||||
|
||||
const getUsableBeamViewerUrl = (url: string | undefined): string | undefined => {
|
||||
const getUsableBeamViewerUrl = (url: string | null | undefined): string | undefined => {
|
||||
if (!url) return undefined;
|
||||
try {
|
||||
const parsedUrl = new URL(url, "http://localhost");
|
||||
@@ -75,7 +81,10 @@ const getUsableBeamViewerUrl = (url: string | undefined): string | undefined =>
|
||||
}
|
||||
};
|
||||
|
||||
const withBeamViewerSettingsSrc = (url: string | undefined, settingsSrc: string | undefined): string | undefined => {
|
||||
const withBeamViewerSettingsSrc = (
|
||||
url: string | null | undefined,
|
||||
settingsSrc: string | undefined
|
||||
): string | undefined => {
|
||||
if (!url || !settingsSrc) return url;
|
||||
try {
|
||||
const parsedUrl = new URL(url, "http://localhost");
|
||||
@@ -99,9 +108,20 @@ const getBeamConversionStatusLabel = (status: string | undefined): string | null
|
||||
if (status === "ready") return "Модель готова";
|
||||
if (status === "failed") return "Ошибка дерева";
|
||||
if (status === "processing") return "Собираем дерево";
|
||||
if (status === "conversion_required") return "Ожидает очереди";
|
||||
return "Ожидает дерево";
|
||||
};
|
||||
|
||||
const sanitizeBeamStatusMessage = (message: string | undefined): string | undefined =>
|
||||
message
|
||||
?.replace(/XKT-просмотр/g, "просмотр")
|
||||
.replace(/XKT и дерева/g, "модели и дерева")
|
||||
.replace(/GLB\/XKT/g, "модели")
|
||||
.replace(/XKT/g, "просмотра");
|
||||
|
||||
const getBeamVersionRecordKey = (version: TBeamModelVersionRecord): string =>
|
||||
version.versionId || `version-${version.version}`;
|
||||
|
||||
const buildSyncedBeamViewerAttachment = (
|
||||
beamViewer: TBeamViewerAttachment,
|
||||
status: TBeamConversionStatus,
|
||||
@@ -112,9 +132,13 @@ const buildSyncedBeamViewerAttachment = (
|
||||
const artifactType = status.artifactType || status.targetFormat || "gltf";
|
||||
const targetFormat: "glb" | "xkt" = status.targetFormat || (artifactType === "xkt" ? "xkt" : "glb");
|
||||
|
||||
return {
|
||||
return syncCurrentBeamVersionRecord({
|
||||
...beamViewer,
|
||||
assetId: beamViewer.assetId || status.assetId,
|
||||
previewAvailable: true,
|
||||
sha256: beamViewer.sha256 || status.sha256,
|
||||
version: beamViewer.version || status.version,
|
||||
versionId: beamViewer.versionId || status.versionId,
|
||||
viewerUrl: buildBeamViewerUrl({
|
||||
name: fullFileName,
|
||||
settingsSrc: status.sourceSrc || beamViewer.conversion?.sourceSrc || beamViewer.src,
|
||||
@@ -122,19 +146,20 @@ const buildSyncedBeamViewerAttachment = (
|
||||
type: artifactType,
|
||||
}),
|
||||
conversion: {
|
||||
...(beamViewer.conversion ?? {}),
|
||||
...beamViewer.conversion,
|
||||
artifactSrc: status.artifactSrc,
|
||||
artifactType,
|
||||
componentTreeRequired: status.componentTreeRequired ?? beamViewer.conversion?.componentTreeRequired ?? true,
|
||||
message: status.message || beamViewer.conversion?.message,
|
||||
metadataSrc: status.metadataSrc,
|
||||
size: status.size ?? beamViewer.conversion?.size,
|
||||
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) {
|
||||
@@ -151,6 +176,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
} = props;
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const { data: currentUser } = useUser();
|
||||
const {
|
||||
attachment: { getAttachmentById },
|
||||
fetchAttachments,
|
||||
@@ -170,7 +196,9 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
const [beamConversionError, setBeamConversionError] = useState<string | null>(null);
|
||||
const attachmentService = useMemo(() => new IssueAttachmentService(issueServiceType), [issueServiceType]);
|
||||
const resolvedArtifactUrl = beamConversionStatus?.status === "ready" ? beamConversionStatus.artifactUrl : undefined;
|
||||
const storedModelViewerUrl = getUsableBeamViewerUrl(beamViewer?.viewerUrl);
|
||||
const beamEffectiveStatus = beamConversionStatus?.status ?? beamViewer?.conversion?.status;
|
||||
const storedModelViewerUrl =
|
||||
beamEffectiveStatus === "ready" ? getUsableBeamViewerUrl(beamViewer?.viewerUrl) : undefined;
|
||||
const beamViewerSettingsSrc = beamConversionStatus?.sourceSrc || beamViewer?.conversion?.sourceSrc || beamViewer?.src;
|
||||
const storedModelViewerUrlWithSettings = withBeamViewerSettingsSrc(storedModelViewerUrl, beamViewerSettingsSrc);
|
||||
const modelViewerUrl =
|
||||
@@ -185,24 +213,37 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
const modelDownloadUrl = beamViewer?.downloadUrl || beamViewer?.src;
|
||||
const previewDownloadUrl = modelDownloadUrl || fileURL;
|
||||
const isBeamModel = isBeamModelFile(fullFileName);
|
||||
const beamEffectiveStatus = beamConversionStatus?.status ?? beamViewer?.conversion?.status;
|
||||
const beamConversionStatusLabel = getBeamConversionStatusLabel(beamEffectiveStatus);
|
||||
const beamStatusErrorMessage =
|
||||
beamConversionError ||
|
||||
beamConversionStatus?.error ||
|
||||
(beamConversionStatus?.status === "ready" && !modelViewerUrl
|
||||
? "Beam вернул статус готовности, но не вернул viewer-артефакт."
|
||||
? "BIM Viewer вернул статус готовности, но не вернул viewer-артефакт."
|
||||
: null);
|
||||
const isBeamConversionFailed = beamEffectiveStatus === "failed" || !!beamStatusErrorMessage;
|
||||
const isBeamAwaitingPreview = !!beamViewer && !modelViewerUrl && !isBeamConversionFailed;
|
||||
const beamStatusTooltipContent = isBeamConversionFailed
|
||||
const rawBeamStatusTooltipContent = isBeamConversionFailed
|
||||
? beamStatusErrorMessage || beamConversionStatus?.message || "Ошибка подготовки дерева компонентов."
|
||||
: beamConversionStatus?.message ||
|
||||
beamViewer?.conversion?.message ||
|
||||
"Оригинальная модель загружена в Beam. Preview появится после подготовки GLB/XKT и дерева компонентов.";
|
||||
"Оригинальная модель загружена в BIM-хранилище. Preview появится после подготовки модели и дерева компонентов.";
|
||||
const beamStatusTooltipContent = sanitizeBeamStatusMessage(rawBeamStatusTooltipContent) || "";
|
||||
const beamStatusTooltipMessage =
|
||||
beamStatusTooltipContent &&
|
||||
!["ready", "processing", "failed", "conversion_required"].includes(beamStatusTooltipContent)
|
||||
? beamStatusTooltipContent
|
||||
: "";
|
||||
const beamStatusIconTooltipContent = beamConversionStatusLabel
|
||||
? `${beamConversionStatusLabel}${beamStatusTooltipMessage ? `. ${beamStatusTooltipMessage}` : ""}`
|
||||
: beamStatusTooltipContent;
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [isVersionHistoryOpen, setIsVersionHistoryOpen] = useState(false);
|
||||
const [deletingVersionKey, setDeletingVersionKey] = useState<string | null>(null);
|
||||
const [isVersionUploading, setIsVersionUploading] = useState(false);
|
||||
const [isThumbnailError, setIsThumbnailError] = useState(false);
|
||||
const rawVersion = attachment?.attributes.version;
|
||||
const versionUploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const beamVersions = useMemo(() => getBeamModelVersionRecords(beamViewer, attachment), [attachment, beamViewer]);
|
||||
const rawVersion = beamViewer?.version ?? attachment?.attributes.version;
|
||||
const versionLabel =
|
||||
typeof rawVersion === "number"
|
||||
? `v${rawVersion}`
|
||||
@@ -211,8 +252,14 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
? rawVersion
|
||||
: `v${rawVersion}`
|
||||
: "v1";
|
||||
const currentVersionId = beamViewer?.versionId;
|
||||
const uploadedAt = renderFormattedDate(attachment?.created_at ?? attachment?.updated_at ?? "");
|
||||
const uploadedBy = attachment?.created_by ? (getUserDetails(attachment.created_by)?.display_name ?? "—") : "—";
|
||||
const getVersionAuthorName = (version: TBeamModelVersionRecord): string => {
|
||||
const userId = version.uploadedBy || attachment?.created_by;
|
||||
if (!userId) return "—";
|
||||
return getUserDetails(userId)?.display_name ?? "—";
|
||||
};
|
||||
const openModelViewer = () => {
|
||||
if (!modelViewerUrl) return;
|
||||
dispatchBeamViewerOpenEvent({
|
||||
@@ -224,6 +271,88 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
viewerUrl: modelViewerUrl,
|
||||
});
|
||||
};
|
||||
const openBeamVersionViewer = (version: TBeamModelVersionRecord) => {
|
||||
const viewerUrl = getBeamVersionViewerUrl(version);
|
||||
if (!viewerUrl) return;
|
||||
|
||||
setIsVersionHistoryOpen(false);
|
||||
dispatchBeamViewerOpenEvent({
|
||||
downloadUrl: version.downloadUrl,
|
||||
fileExtension: getFileExtension(version.originalFilename),
|
||||
fileName: version.originalFilename,
|
||||
fileSize: version.size || version.conversion?.size || 0,
|
||||
issueId,
|
||||
viewerUrl,
|
||||
});
|
||||
};
|
||||
const startVersionUpload = () => {
|
||||
versionUploadInputRef.current?.click();
|
||||
};
|
||||
const handleVersionFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file || !attachment || !beamViewer) return;
|
||||
|
||||
if (!isBeamModelFile(file.name)) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Формат не поддержан",
|
||||
message: "Для новой версии нужно выбрать файл модели, который поддерживает BIM Viewer.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsVersionUploading(true);
|
||||
try {
|
||||
await attachmentService.uploadBeamIssueAttachmentVersion(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
attachment,
|
||||
file,
|
||||
undefined,
|
||||
currentUser?.id
|
||||
);
|
||||
await fetchAttachments(workspaceSlug, projectId, issueId);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Версия добавлена",
|
||||
message: `${file.name} загружен как новая версия модели.`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in uploading Beam model version:", error);
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Версия не загружена",
|
||||
message: error instanceof Error ? error.message : "Не удалось загрузить новую версию модели.",
|
||||
});
|
||||
} finally {
|
||||
setIsVersionUploading(false);
|
||||
}
|
||||
};
|
||||
const handleDeleteVersion = async (version: TBeamModelVersionRecord) => {
|
||||
if (!attachment || !beamViewer) return;
|
||||
|
||||
const versionKey = getBeamVersionRecordKey(version);
|
||||
setDeletingVersionKey(versionKey);
|
||||
try {
|
||||
await attachmentService.deleteBeamIssueAttachmentVersion(workspaceSlug, projectId, issueId, attachment, version);
|
||||
await fetchAttachments(workspaceSlug, projectId, issueId);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Версия удалена",
|
||||
message: `v${version.version} удалена из истории модели.`,
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Версия не удалена",
|
||||
message: error instanceof Error ? error.message : "Не удалось удалить версию модели.",
|
||||
});
|
||||
} finally {
|
||||
setDeletingVersionKey(null);
|
||||
}
|
||||
};
|
||||
const menuItems: TContextMenuItem[] = [
|
||||
...(modelViewerUrl
|
||||
? [
|
||||
@@ -235,6 +364,23 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(beamViewer
|
||||
? [
|
||||
{
|
||||
key: "upload-model-version",
|
||||
action: startVersionUpload,
|
||||
title: isVersionUploading ? "Загружаем версию..." : "Добавить версию",
|
||||
icon: UploadCloud,
|
||||
disabled: isVersionUploading,
|
||||
},
|
||||
{
|
||||
key: "model-version-history",
|
||||
action: () => setIsVersionHistoryOpen(true),
|
||||
title: "История версий",
|
||||
icon: History,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(modelDownloadUrl
|
||||
? [
|
||||
{
|
||||
@@ -258,9 +404,19 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
];
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
const versionUploadInput = beamViewer ? (
|
||||
<input
|
||||
ref={versionUploadInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
disabled={isVersionUploading}
|
||||
onChange={handleVersionFileChange}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!beamViewer || storedModelViewerUrl || !beamViewer.src) return;
|
||||
if (!beamViewer || !beamViewer.src) return;
|
||||
if (beamViewer.conversion?.status === "ready" && storedModelViewerUrl) return;
|
||||
|
||||
let isMounted = true;
|
||||
let timeoutId: number | undefined;
|
||||
@@ -283,7 +439,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMounted) return;
|
||||
setBeamConversionError(error instanceof Error ? error.message : "Не удалось получить статус Beam.");
|
||||
setBeamConversionError(error instanceof Error ? error.message : "Не удалось получить статус BIM-модели.");
|
||||
timeoutId = window.setTimeout(pollStatus, 8000);
|
||||
}
|
||||
};
|
||||
@@ -311,7 +467,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
if (viewMode === "list") {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_112px] items-center gap-3 border-b border-subtle/70 px-4 py-3 text-12 transition hover:bg-white/[0.035]">
|
||||
{versionUploadInput}
|
||||
<div className="grid grid-cols-[minmax(240px,1.45fr)_120px_86px_minmax(130px,0.9fr)_192px] items-center gap-3 border-b border-subtle/70 px-4 py-3 text-12 transition hover:bg-white/[0.035]">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 items-center gap-3 text-left"
|
||||
@@ -322,25 +479,33 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
else setIsPreviewOpen(true);
|
||||
}}
|
||||
>
|
||||
<div className="grid size-9 flex-shrink-0 place-items-center rounded-xl bg-surface-1 text-tertiary">
|
||||
{isBeamModel ? (
|
||||
isBeamConversionFailed ? (
|
||||
<AlertTriangle className="text-red-400 size-5" />
|
||||
) : isBeamAwaitingPreview ? (
|
||||
<Spinner
|
||||
height="20px"
|
||||
width="20px"
|
||||
className="fill-[rgb(var(--nodedc-accent-rgb))] text-[rgba(var(--nodedc-accent-rgb),0.18)]"
|
||||
/>
|
||||
<Tooltip
|
||||
tooltipContent={isBeamModel ? beamStatusIconTooltipContent : fullFileName}
|
||||
isMobile={isMobile}
|
||||
position={isBeamModel ? "left" : "top"}
|
||||
sideOffset={isBeamModel ? 6 : 10}
|
||||
className={isBeamModel ? "z-[220]" : ""}
|
||||
>
|
||||
<div className="grid size-9 flex-shrink-0 place-items-center rounded-xl bg-surface-1 text-tertiary">
|
||||
{isBeamModel ? (
|
||||
isBeamConversionFailed ? (
|
||||
<AlertTriangle className="text-red-400 size-5" />
|
||||
) : isBeamAwaitingPreview ? (
|
||||
<Spinner
|
||||
height="20px"
|
||||
width="20px"
|
||||
className="fill-[rgb(var(--nodedc-accent-rgb))] text-[rgba(var(--nodedc-accent-rgb),0.18)]"
|
||||
/>
|
||||
) : (
|
||||
<Box className="size-5 text-[rgb(var(--nodedc-accent-rgb))]" />
|
||||
)
|
||||
) : previewType === "file" ? (
|
||||
getFileIcon(fileExtension, 18)
|
||||
) : (
|
||||
<Box className="size-5 text-[rgb(var(--nodedc-accent-rgb))]" />
|
||||
)
|
||||
) : previewType === "file" ? (
|
||||
getFileIcon(fileExtension, 18)
|
||||
) : (
|
||||
<ImageIcon className="size-5" />
|
||||
)}
|
||||
</div>
|
||||
<ImageIcon className="size-5" />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="min-w-0">
|
||||
<Tooltip tooltipContent={fullFileName} isMobile={isMobile}>
|
||||
<div className="truncate font-semibold text-secondary">{fullFileName}</div>
|
||||
@@ -349,24 +514,6 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
<span>{fileExtension ? fileExtension.toUpperCase() : "FILE"}</span>
|
||||
<span className="size-1 rounded-full bg-layer-1" />
|
||||
<span>{convertBytesToSize(attachment.attributes.size)}</span>
|
||||
{beamConversionStatusLabel && (
|
||||
<>
|
||||
<span className="size-1 rounded-full bg-layer-1" />
|
||||
<Tooltip tooltipContent={beamStatusTooltipContent} isMobile={isMobile}>
|
||||
<span
|
||||
className={
|
||||
isBeamConversionFailed
|
||||
? "text-red-400"
|
||||
: isBeamAwaitingPreview
|
||||
? "text-[rgb(var(--nodedc-accent-rgb))]"
|
||||
: "text-tertiary"
|
||||
}
|
||||
>
|
||||
{beamConversionStatusLabel}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -374,6 +521,40 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
<div className="text-secondary">{versionLabel}</div>
|
||||
<div className="truncate text-tertiary">{uploadedBy}</div>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{beamViewer && (
|
||||
<Tooltip tooltipContent="История версий" isMobile={isMobile}>
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-8 place-items-center rounded-full text-tertiary transition hover:bg-surface-1 hover:text-primary"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsVersionHistoryOpen(true);
|
||||
}}
|
||||
>
|
||||
<History className="size-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{beamViewer && (
|
||||
<Tooltip
|
||||
tooltipContent={isVersionUploading ? "Загружаем версию..." : "Добавить версию"}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-8 place-items-center rounded-full text-tertiary transition hover:bg-surface-1 hover:text-primary disabled:pointer-events-none disabled:opacity-40"
|
||||
disabled={isVersionUploading}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
startVersionUpload();
|
||||
}}
|
||||
>
|
||||
<UploadCloud className="size-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{(modelViewerUrl || previewURL) && (
|
||||
<Tooltip
|
||||
tooltipContent={modelViewerUrl ? "Посмотреть модель" : "Открыть предпросмотр"}
|
||||
@@ -446,12 +627,24 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
size={attachment.attributes.size}
|
||||
/>
|
||||
</ModalCore>
|
||||
<BeamVersionHistoryModal
|
||||
currentVersionId={currentVersionId}
|
||||
deletingVersionKey={deletingVersionKey}
|
||||
fileName={fullFileName}
|
||||
getVersionAuthorName={getVersionAuthorName}
|
||||
isOpen={isVersionHistoryOpen}
|
||||
onClose={() => setIsVersionHistoryOpen(false)}
|
||||
onDeleteVersion={handleDeleteVersion}
|
||||
onOpenVersion={openBeamVersionViewer}
|
||||
versions={beamVersions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{versionUploadInput}
|
||||
<div className="group flex h-32 w-72 flex-shrink-0 overflow-hidden rounded-2xl border border-subtle bg-surface-2/80 transition hover:border-[rgba(var(--nodedc-accent-rgb),0.45)] hover:bg-surface-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -571,6 +764,17 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
|
||||
size={attachment.attributes.size}
|
||||
/>
|
||||
</ModalCore>
|
||||
<BeamVersionHistoryModal
|
||||
currentVersionId={currentVersionId}
|
||||
deletingVersionKey={deletingVersionKey}
|
||||
fileName={fullFileName}
|
||||
getVersionAuthorName={getVersionAuthorName}
|
||||
isOpen={isVersionHistoryOpen}
|
||||
onClose={() => setIsVersionHistoryOpen(false)}
|
||||
onDeleteVersion={handleDeleteVersion}
|
||||
onOpenVersion={openBeamVersionViewer}
|
||||
versions={beamVersions}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -674,7 +878,7 @@ const AttachmentPreviewContent = (props: TAttachmentPreviewContent) => {
|
||||
<div className="mt-2 text-14 text-secondary">
|
||||
{isBeamConversionFailed
|
||||
? beamStatusTooltipContent
|
||||
: "Оригинальный STEP загружен в Beam. Preview появится после подготовки GLB/XKT и дерева компонентов."}
|
||||
: "Оригинальный STEP загружен в BIM-хранилище. Preview появится после подготовки модели и дерева компонентов."}
|
||||
</div>
|
||||
</div>
|
||||
{modelDownloadUrl && (
|
||||
@@ -702,3 +906,150 @@ const AttachmentPreviewContent = (props: TAttachmentPreviewContent) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type TBeamVersionHistoryModal = {
|
||||
currentVersionId: string | undefined;
|
||||
deletingVersionKey: string | null;
|
||||
fileName: string;
|
||||
getVersionAuthorName: (version: TBeamModelVersionRecord) => string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onDeleteVersion: (version: TBeamModelVersionRecord) => void;
|
||||
onOpenVersion: (version: TBeamModelVersionRecord) => void;
|
||||
versions: TBeamModelVersionRecord[];
|
||||
};
|
||||
|
||||
const BeamVersionHistoryModal = (props: TBeamVersionHistoryModal) => {
|
||||
const {
|
||||
currentVersionId,
|
||||
deletingVersionKey,
|
||||
fileName,
|
||||
getVersionAuthorName,
|
||||
isOpen,
|
||||
onClose,
|
||||
onDeleteVersion,
|
||||
onOpenVersion,
|
||||
versions,
|
||||
} = props;
|
||||
const latestVersion = versions.reduce((latest, version) => Math.max(latest, version.version), 0);
|
||||
|
||||
return (
|
||||
<ModalCore
|
||||
isOpen={isOpen}
|
||||
handleClose={onClose}
|
||||
position={EModalPosition.CENTER}
|
||||
width={EModalWidth.VIIXL}
|
||||
className="max-h-[76vh] w-[min(96vw,1280px)] overflow-hidden bg-surface-1"
|
||||
>
|
||||
<div className="flex max-h-[76vh] min-h-0 flex-col bg-surface-1">
|
||||
<div className="flex flex-shrink-0 items-center justify-between gap-4 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-15 truncate font-semibold text-primary">История версий</div>
|
||||
<div className="mt-1 truncate text-12 text-tertiary">{fileName}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-surface-3 grid size-9 place-items-center rounded-full bg-surface-2 text-tertiary transition hover:text-primary"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||
{versions.length === 0 ? (
|
||||
<div className="rounded-2xl bg-surface-2/60 px-4 py-8 text-center text-13 text-tertiary">
|
||||
История версий пока не создана.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-2xl bg-surface-2/40">
|
||||
<div className="min-w-0">
|
||||
<div className="grid grid-cols-[minmax(260px,1.6fr)_80px_96px_140px_140px_150px_120px] gap-3 px-4 py-2 text-11 font-semibold tracking-[0.05em] text-tertiary uppercase">
|
||||
<div>Файл</div>
|
||||
<div>Версия</div>
|
||||
<div>Размер</div>
|
||||
<div>Автор</div>
|
||||
<div>Дата</div>
|
||||
<div>Статус</div>
|
||||
<div className="text-right">Действия</div>
|
||||
</div>
|
||||
{versions
|
||||
.toSorted((first, second) => second.version - first.version)
|
||||
.map((version) => {
|
||||
const viewerUrl = getBeamVersionViewerUrl(version);
|
||||
const isCurrent = currentVersionId
|
||||
? version.versionId === currentVersionId
|
||||
: version.version === latestVersion;
|
||||
const statusLabel = getBeamConversionStatusLabel(version.conversion?.status) ?? "Модель готова";
|
||||
const versionKey = getBeamVersionRecordKey(version);
|
||||
const isDeletingVersion = deletingVersionKey === versionKey;
|
||||
const authorName = getVersionAuthorName(version);
|
||||
const versionSize = version.size || version.conversion?.size || 0;
|
||||
return (
|
||||
<div
|
||||
key={versionKey}
|
||||
className="grid grid-cols-[minmax(260px,1.6fr)_80px_96px_140px_140px_150px_120px] items-center gap-3 border-b border-subtle/70 px-4 py-3 text-12 transition last:border-b-0 hover:bg-surface-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-secondary">{version.originalFilename}</div>
|
||||
{version.sha256 && (
|
||||
<div className="mt-1 truncate text-10 text-tertiary">sha256 {version.sha256}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="font-semibold text-secondary">
|
||||
v{version.version}
|
||||
{isCurrent && (
|
||||
<span className="mt-1 block text-10 font-medium text-[rgb(var(--nodedc-accent-rgb))]">
|
||||
текущая
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-tertiary">{convertBytesToSize(versionSize)}</div>
|
||||
<div className="truncate text-tertiary">{authorName}</div>
|
||||
<div className="truncate text-tertiary">{renderFormattedDate(version.uploadedAt)}</div>
|
||||
<div className="truncate text-tertiary">{statusLabel}</div>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{viewerUrl && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-8 place-items-center rounded-full text-tertiary transition hover:bg-surface-1 hover:text-primary"
|
||||
onClick={() => onOpenVersion(version)}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
{version.downloadUrl && (
|
||||
<a
|
||||
href={version.downloadUrl}
|
||||
download={version.originalFilename}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="grid size-8 place-items-center rounded-full text-tertiary transition hover:bg-surface-1 hover:text-primary"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</a>
|
||||
)}
|
||||
<Tooltip
|
||||
tooltipContent={isCurrent ? "Текущую версию нельзя удалить" : "Удалить версию из истории"}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-red-500/10 hover:text-red-400 grid size-8 place-items-center rounded-full text-tertiary transition disabled:pointer-events-none disabled:opacity-40"
|
||||
disabled={isCurrent || isDeletingVersion}
|
||||
onClick={() => onDeleteVersion(version)}
|
||||
>
|
||||
{isDeletingVersion ? <Spinner className="size-3.5" /> : <TrashIcon className="size-4" />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -574,7 +574,7 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<section className="nodedc-beam-peek-viewer-card" aria-label="Beam Viewer">
|
||||
<section className="nodedc-beam-peek-viewer-card" aria-label="BIM Viewer">
|
||||
<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="max-w-[min(44vw,34rem)] min-w-0 text-center text-white drop-shadow-[0_2px_10px_rgba(0,0,0,0.75)]">
|
||||
@@ -617,10 +617,11 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* eslint-disable-next-line react/iframe-missing-sandbox -- BIM Viewer needs its scripted same-origin context for controls and settings. */}
|
||||
<iframe
|
||||
key={beamPeekViewer.viewerUrl}
|
||||
src={beamPeekViewer.viewerUrl}
|
||||
title={`Beam Viewer: ${beamPeekViewer.fileName}`}
|
||||
title={`BIM Viewer: ${beamPeekViewer.fileName}`}
|
||||
tabIndex={-1}
|
||||
className="h-full w-full border-0 ring-0 outline-none focus:ring-0 focus:outline-none"
|
||||
allow="fullscreen"
|
||||
|
||||
@@ -62,7 +62,7 @@ const StatCard = (props: {
|
||||
return (
|
||||
<div className="nodedc-settings-card p-5">
|
||||
<div className="mb-5 flex items-start justify-between gap-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-tertiary">{title}</div>
|
||||
<div className="text-[11px] font-semibold tracking-[0.18em] text-tertiary uppercase">{title}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-full bg-white/5 text-secondary",
|
||||
@@ -73,7 +73,7 @@ const StatCard = (props: {
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-semibold tracking-normal text-primary">{value}</div>
|
||||
<div className="text-3xl tracking-normal font-semibold text-primary">{value}</div>
|
||||
<div className="mt-2 text-12 leading-5 text-secondary">{caption}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -92,7 +92,7 @@ const ProjectStorageRow = (props: {
|
||||
<div className="nodedc-settings-field grid min-w-[78rem] grid-cols-[minmax(14rem,1.35fr)_0.48fr_0.48fr_minmax(14rem,1.25fr)_0.7fr_0.7fr_minmax(14rem,1.2fr)_0.6fr_0.6fr_0.6fr] items-center gap-4 px-4 py-3.5">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-14 font-semibold text-primary">{project.name}</span>
|
||||
<span className="mt-1 text-11 uppercase tracking-[0.16em] text-tertiary">{project.identifier}</span>
|
||||
<span className="mt-1 text-11 tracking-[0.16em] text-tertiary uppercase">{project.identifier}</span>
|
||||
</div>
|
||||
<StorageValue>{formatCount(project.file_count)}</StorageValue>
|
||||
<StorageValue>{formatCount(project.blob_count)}</StorageValue>
|
||||
@@ -106,7 +106,9 @@ const ProjectStorageRow = (props: {
|
||||
<StorageValue accent>{formatBytes(project.dedup_savings)}</StorageValue>
|
||||
<ProjectQuotaControl project={project} workspaceSlug={workspaceSlug} onRefresh={onRefresh} />
|
||||
<StorageValue warning={project.failed_upload_count > 0}>{formatCount(project.failed_upload_count)}</StorageValue>
|
||||
<StorageValue warning={project.stale_unuploaded_count > 0}>{formatCount(project.stale_unuploaded_count)}</StorageValue>
|
||||
<StorageValue warning={project.stale_unuploaded_count > 0}>
|
||||
{formatCount(project.stale_unuploaded_count)}
|
||||
</StorageValue>
|
||||
<StorageValue warning={project.soft_deleted_count > 0}>{formatCount(project.soft_deleted_count)}</StorageValue>
|
||||
</div>
|
||||
);
|
||||
@@ -125,7 +127,7 @@ const StorageValue = (props: { accent?: boolean; children: string; warning?: boo
|
||||
);
|
||||
|
||||
const ProjectStorageHeader = () => (
|
||||
<div className="grid min-w-[78rem] grid-cols-[minmax(14rem,1.35fr)_0.48fr_0.48fr_minmax(14rem,1.25fr)_0.7fr_0.7fr_minmax(14rem,1.2fr)_0.6fr_0.6fr_0.6fr] gap-4 px-4 text-[11px] font-semibold uppercase tracking-[0.16em] text-tertiary">
|
||||
<div className="grid min-w-[78rem] grid-cols-[minmax(14rem,1.35fr)_0.48fr_0.48fr_minmax(14rem,1.25fr)_0.7fr_0.7fr_minmax(14rem,1.2fr)_0.6fr_0.6fr_0.6fr] gap-4 px-4 text-[11px] font-semibold tracking-[0.16em] text-tertiary uppercase">
|
||||
<span>Проект</span>
|
||||
<span>Файлы</span>
|
||||
<span>Blob</span>
|
||||
@@ -173,7 +175,7 @@ const ProjectQuotaControl = (props: {
|
||||
title: "Квота обновлена",
|
||||
message: isEnabled ? "Лимит проекта сохранен." : "Лимит проекта отключен.",
|
||||
});
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Квота не сохранена",
|
||||
@@ -200,34 +202,40 @@ const ProjectQuotaControl = (props: {
|
||||
<Database className="size-3.5" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 rounded-full bg-white/6 px-3 py-1.5">
|
||||
<input
|
||||
className="min-w-0 flex-1 bg-transparent text-12 font-medium text-primary outline-none placeholder:text-tertiary"
|
||||
disabled={isSaving}
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
onChange={(event) => setQuotaMb(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
saveQuota();
|
||||
}
|
||||
}}
|
||||
type="number"
|
||||
value={quotaMb}
|
||||
/>
|
||||
<span className="shrink-0 text-11 font-medium text-tertiary">МБ</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 truncate text-[11px] text-tertiary",
|
||||
project.quota_warning && "text-yellow-200",
|
||||
project.quota_exceeded && "text-red-200"
|
||||
)}
|
||||
>
|
||||
{project.quota_enabled
|
||||
? `${formatBytes(project.quota_used)} из ${formatBytes(project.quota)}`
|
||||
: "без лимита"}
|
||||
</div>
|
||||
{isEnabled ? (
|
||||
<div className="flex items-center gap-2 rounded-full bg-white/6 px-3 py-1.5">
|
||||
<input
|
||||
className="min-w-0 flex-1 bg-transparent text-12 font-medium text-primary outline-none placeholder:text-tertiary"
|
||||
disabled={isSaving}
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
onChange={(event) => setQuotaMb(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
saveQuota();
|
||||
}
|
||||
}}
|
||||
type="number"
|
||||
value={quotaMb}
|
||||
/>
|
||||
<span className="shrink-0 text-11 font-medium text-tertiary">МБ</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-full bg-white/6 px-3 py-1.5 text-center text-12 font-medium text-primary">
|
||||
Без лимита
|
||||
</div>
|
||||
)}
|
||||
{project.quota_enabled && (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 truncate text-[11px] text-tertiary",
|
||||
project.quota_warning && "text-yellow-200",
|
||||
project.quota_exceeded && "text-red-200"
|
||||
)}
|
||||
>
|
||||
{`${formatBytes(project.quota_used)} из ${formatBytes(project.quota)}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -269,8 +277,8 @@ const MaintenanceCard = ({
|
||||
<div className="nodedc-settings-card flex min-h-40 flex-col justify-between gap-5 p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-tertiary">{title}</div>
|
||||
<div className="mt-3 text-2xl font-semibold tracking-normal text-primary">{value}</div>
|
||||
<div className="text-[11px] font-semibold tracking-[0.18em] text-tertiary uppercase">{title}</div>
|
||||
<div className="text-2xl tracking-normal mt-3 font-semibold text-primary">{value}</div>
|
||||
<div className="mt-2 text-12 leading-5 text-secondary">{caption}</div>
|
||||
</div>
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-white/5 text-secondary">
|
||||
@@ -300,7 +308,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
|
||||
([, slug]) => workspaceService.fetchWorkspaceStorageSummary(slug)
|
||||
);
|
||||
|
||||
const projects = [...(data?.projects ?? [])].sort((a, b) => b.logical_size - a.logical_size);
|
||||
const projects = [...(data?.projects ?? [])].toSorted((a, b) => b.logical_size - a.logical_size);
|
||||
const maxProjectSize = Math.max(...projects.map((project) => project.logical_size), 0);
|
||||
|
||||
const runMaintenance = async (action: TWorkspaceStorageMaintenanceAction) => {
|
||||
@@ -313,7 +321,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
|
||||
title: "Хранилище обновлено",
|
||||
message: getMaintenanceResultMessage(result.action, result),
|
||||
});
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Действие не выполнено",
|
||||
@@ -333,14 +341,14 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
|
||||
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="nodedc-settings-card h-36 animate-pulse" />
|
||||
{["logical", "physical", "dedup", "problems"].map((skeletonKey) => (
|
||||
<div key={skeletonKey} className="nodedc-settings-card h-36 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="nodedc-settings-card bg-red-500/10 px-5 py-4 text-14 text-red-200">
|
||||
<div className="nodedc-settings-card bg-red-500/10 text-red-200 px-5 py-4 text-14">
|
||||
Не удалось загрузить данные хранилища.
|
||||
</div>
|
||||
)}
|
||||
@@ -415,7 +423,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
|
||||
<section>
|
||||
<div className="mb-4 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-normal text-primary">Обслуживание</h2>
|
||||
<h2 className="text-lg tracking-normal font-semibold text-primary">Обслуживание</h2>
|
||||
<p className="mt-1 text-13 text-secondary">
|
||||
Ручные действия для диагностики и очистки файловой помойки без фонового удаления при открытии.
|
||||
</p>
|
||||
@@ -470,7 +478,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
|
||||
<section className="nodedc-settings-card p-5">
|
||||
<div className="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-normal text-primary">Проекты</h2>
|
||||
<h2 className="text-lg tracking-normal font-semibold text-primary">Проекты</h2>
|
||||
<p className="mt-1 text-13 text-secondary">Сортировка по логическому объему файлов.</p>
|
||||
</div>
|
||||
<div className="nodedc-settings-chip flex min-h-0 items-center px-4 py-2 text-12 font-medium text-secondary">
|
||||
|
||||
Reference in New Issue
Block a user