From 81abcb5b3677cbde9325e99ed8c1d23ad864f21e Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 5 Jun 2026 14:45:48 +0300 Subject: [PATCH] feat: refine Beam attachments and peek layouts --- .../web/core/components/dropdowns/date.tsx | 5 +- .../attachment/attachment-item-list.tsx | 117 +++++- .../attachment/attachment-list-item.tsx | 391 +++++++++++++----- .../issues/peek-overview/header.tsx | 75 +--- .../components/issues/peek-overview/view.tsx | 6 +- plane-src/apps/web/styles/globals.css | 42 ++ 6 files changed, 455 insertions(+), 181 deletions(-) diff --git a/plane-src/apps/web/core/components/dropdowns/date.tsx b/plane-src/apps/web/core/components/dropdowns/date.tsx index 951d344..7402c07 100644 --- a/plane-src/apps/web/core/components/dropdowns/date.tsx +++ b/plane-src/apps/web/core/components/dropdowns/date.tsx @@ -213,10 +213,7 @@ export const DateDropdown = observer(function DateDropdown(props: Props) { createPortal(
(() => { + if (typeof window === "undefined") return "grid"; + return window.localStorage.getItem("nodedc-issue-attachment-view-mode") === "list" ? "list" : "grid"; + }); // store hooks const { attachment: { getAttachmentsByIssueId }, @@ -67,6 +74,7 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList activeUploads.length > 0 ? Math.round(activeUploads.reduce((progressSum, item) => progressSum + item.progress, 0) / activeUploads.length) : 0; + const isListView = attachmentViewMode === "list"; // handlers const handleFetchPropertyActivities = useCallback(() => { @@ -122,6 +130,14 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList disabled: isUploading || disabled, }); + const handleToggleAttachmentViewMode = useCallback(() => { + setAttachmentViewMode((currentMode) => { + const nextMode: TAttachmentViewMode = currentMode === "grid" ? "list" : "grid"; + if (typeof window !== "undefined") window.localStorage.setItem("nodedc-issue-attachment-view-mode", nextMode); + return nextMode; + }); + }, []); + return (
{attachmentDeleteModalId && ( @@ -173,29 +189,90 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList )} {hasAttachmentRows && ( -
+
Вложения
-
- {activeUploads.length > 0 ? `Загрузка ${uploadProgress}%` : issueAttachments.length} +
+
+ {activeUploads.length > 0 ? `Загрузка ${uploadProgress}%` : issueAttachments.length} +
+
-
- {uploadStatus?.map((status) => ( - - ))} - {issueAttachments.map((attachmentId) => ( - - ))} -
+ {isListView ? ( +
+
+
+
Файл
+
Дата
+
Версия
+
Добавил
+
Действия
+
+ {uploadStatus?.map((status) => ( +
+
+
{status.name}
+
+ {(getFileExtension(status.name) || "file").toUpperCase()} · {convertBytesToSize(status.size)} +
+
+
Загрузка
+
v1
+
{status.progress}%
+
+
+
+
+ ))} + {issueAttachments.map((attachmentId) => ( + + ))} +
+
+ ) : ( +
+ {uploadStatus?.map((status) => ( + + ))} + {issueAttachments.map((attachmentId) => ( + + ))} +
+ )}
)}
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 1344b5d..ed2b37b 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 @@ -6,6 +6,7 @@ import { observer } from "mobx-react"; import { useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; import { useTranslation } from "@plane/i18n"; import { getIconButtonStyling } from "@plane/propel/icon-button"; @@ -45,6 +46,7 @@ type TIssueAttachmentsListItem = { issueServiceType?: TIssueServiceType; issueId: string; projectId: string; + viewMode?: "grid" | "list"; workspaceSlug: string; }; @@ -144,6 +146,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt issueId, issueServiceType = EIssueServiceType.ISSUES, projectId, + viewMode = "grid", workspaceSlug, } = props; // store hooks @@ -199,6 +202,17 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt "Оригинальная модель загружена в Beam. Preview появится после подготовки GLB/XKT и дерева компонентов."; const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [isThumbnailError, setIsThumbnailError] = useState(false); + const rawVersion = attachment?.attributes.version; + const versionLabel = + typeof rawVersion === "number" + ? `v${rawVersion}` + : typeof rawVersion === "string" + ? rawVersion.toLowerCase().startsWith("v") + ? rawVersion + : `v${rawVersion}` + : "v1"; + const uploadedAt = renderFormattedDate(attachment?.created_at ?? attachment?.updated_at ?? ""); + const uploadedBy = attachment?.created_by ? (getUserDetails(attachment.created_by)?.display_name ?? "—") : "—"; const openModelViewer = () => { if (!modelViewerUrl) return; dispatchBeamViewerOpenEvent({ @@ -294,6 +308,148 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt if (!attachment) return <>; + if (viewMode === "list") { + return ( + <> +
+ +
{uploadedAt}
+
{versionLabel}
+
{uploadedBy}
+
+ {(modelViewerUrl || previewURL) && ( + + + + )} + {previewDownloadUrl && ( + + e.stopPropagation()} + > + + + + )} + + + +
+
+ + setIsPreviewOpen(false)} + position={EModalPosition.CENTER} + width={EModalWidth.VIIXL} + className="h-[calc(100vh-4rem)] max-w-[calc(100vw-4rem)] overflow-hidden border border-subtle bg-surface-1" + > + + + + ); + } + return ( <>
@@ -400,100 +556,149 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt width={EModalWidth.VIIXL} className="h-[calc(100vh-4rem)] max-w-[calc(100vw-4rem)] overflow-hidden border border-subtle bg-surface-1" > -
-
- {previewDownloadUrl && ( - e.stopPropagation()} - > - - - )} - -
- -
-
{fullFileName}
-
{convertBytesToSize(attachment.attributes.size)}
-
- -
- {previewType === "image" && previewURL ? ( -
- {fullFileName} -
- ) : previewType === "video" && previewURL ? ( -
- -
- ) : previewType === "pdf" && previewURL ? ( - - ) : isBeamAwaitingPreview || isBeamConversionFailed ? ( -
-
- {isBeamConversionFailed ? ( - - ) : ( - - )} -
-
-
- {isBeamConversionFailed ? "Ошибка дерева" : (beamConversionStatusLabel ?? "Ожидает дерево")} -
-
- {isBeamConversionFailed - ? beamStatusTooltipContent - : "Оригинальный STEP загружен в Beam. Preview появится после подготовки GLB/XKT и дерева компонентов."} -
-
- {modelDownloadUrl && ( - - - Скачать оригинал - - )} -
- ) : ( -
-
{fileIcon}
-
- Предпросмотр для этого формата недоступен. Файл можно скачать или открыть в новой вкладке. -
-
- )} -
-
+ ); }); + +type TAttachmentPreviewContent = { + beamConversionStatusLabel: string | null; + beamStatusTooltipContent: string; + fileIcon: ReactNode; + fullFileName: string; + isBeamAwaitingPreview: boolean; + isBeamConversionFailed: boolean; + modelDownloadUrl: string | undefined; + previewDownloadUrl: string | undefined; + previewType: "image" | "video" | "pdf" | "file"; + previewURL: string; + setIsPreviewOpen: (isOpen: boolean) => void; + size: number; +}; + +const AttachmentPreviewContent = (props: TAttachmentPreviewContent) => { + const { + beamConversionStatusLabel, + beamStatusTooltipContent, + fileIcon, + fullFileName, + isBeamAwaitingPreview, + isBeamConversionFailed, + modelDownloadUrl, + previewDownloadUrl, + previewType, + previewURL, + setIsPreviewOpen, + size, + } = props; + + return ( +
+
+ {previewDownloadUrl && ( + e.stopPropagation()} + > + + + )} + +
+ +
+
{fullFileName}
+
{convertBytesToSize(size)}
+
+ +
+ {previewType === "image" && previewURL ? ( +
+ {fullFileName} +
+ ) : previewType === "video" && previewURL ? ( +
+ +
+ ) : previewType === "pdf" && previewURL ? ( + + ) : isBeamAwaitingPreview || isBeamConversionFailed ? ( +
+
+ {isBeamConversionFailed ? ( + + ) : ( + + )} +
+
+
+ {isBeamConversionFailed ? "Ошибка дерева" : (beamConversionStatusLabel ?? "Ожидает дерево")} +
+
+ {isBeamConversionFailed + ? beamStatusTooltipContent + : "Оригинальный STEP загружен в Beam. Preview появится после подготовки GLB/XKT и дерева компонентов."} +
+
+ {modelDownloadUrl && ( + + + Скачать оригинал + + )} +
+ ) : ( +
+
{fileIcon}
+
+ Предпросмотр для этого формата недоступен. Файл можно скачать или открыть в новой вкладке. +
+
+ )} +
+
+ ); +}; diff --git a/plane-src/apps/web/core/components/issues/peek-overview/header.tsx b/plane-src/apps/web/core/components/issues/peek-overview/header.tsx index 1176b1f..175bd60 100644 --- a/plane-src/apps/web/core/components/issues/peek-overview/header.tsx +++ b/plane-src/apps/web/core/components/issues/peek-overview/header.tsx @@ -6,16 +6,14 @@ import { useRef, type ReactNode } from "react"; import { observer } from "mobx-react"; -import Link from "next/link"; -import { MoveDiagonal, MoveRight } from "lucide-react"; +import { Maximize2, Minimize2, MoveRight, X } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { CenterPanelIcon, CopyLinkIcon, FullScreenPanelIcon, SidePanelIcon } from "@plane/propel/icons"; +import { CopyLinkIcon } from "@plane/propel/icons"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { Tooltip } from "@plane/propel/tooltip"; import type { TNameDescriptionLoader } from "@plane/types"; import { EIssuesStoreType } from "@plane/types"; -import { SelectionDropdown } from "@/components/common/selection-dropdown"; import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; @@ -31,24 +29,6 @@ import { IconButton } from "@plane/propel/icon-button"; export type TPeekModes = "side-peek" | "modal" | "full-screen"; -const PEEK_OPTIONS: { key: TPeekModes; icon: any; i18n_title: string }[] = [ - { - key: "side-peek", - icon: SidePanelIcon, - i18n_title: "common.side_peek", - }, - { - key: "modal", - icon: CenterPanelIcon, - i18n_title: "common.modal", - }, - { - key: "full-screen", - icon: FullScreenPanelIcon, - i18n_title: "common.full_screen", - }, -]; - export type PeekOverviewHeaderProps = { peekMode: TPeekModes; setPeekMode: (value: TPeekModes) => void; @@ -68,7 +48,6 @@ export type PeekOverviewHeaderProps = { actionSlot?: ReactNode; metaSlot?: ReactNode; showCopyLink?: boolean; - showLayoutSwitcher?: boolean; showQuickActions?: boolean; showSubscription?: boolean; }; @@ -82,7 +61,6 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader issueId, isArchived, disabled, - embedIssue = false, removeRoutePeekId, toggleDeleteIssueModal, toggleArchiveIssueModal, @@ -93,7 +71,6 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader actionSlot, metaSlot, showCopyLink = true, - showLayoutSwitcher = !embedIssue, showQuickActions = true, showSubscription = true, } = props; @@ -113,7 +90,6 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader const { getProjectIdentifierById } = useProject(); // derived values const issueDetails = getIssueById(issueId); - const currentMode = PEEK_OPTIONS.find((m) => m.key === peekMode); const projectIdentifier = getProjectIdentifierById(issueDetails?.project_id); const { issues: { removeIssue: removeArchivedIssue }, @@ -168,50 +144,29 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
- removeRoutePeekId()}> - - + - {currentMode && showLayoutSwitcher && ( -
- - - - - - } - menuButtonWrapperClassName="flex items-center" - options={PEEK_OPTIONS.map((mode) => ({ - key: mode.key, - isChecked: currentMode.key === mode.key, - onClick: () => setPeekMode(mode.key), - title: ( -
- - {t(mode.i18n_title)} -
- ), - }))} - /> -
- )} {metaSlot}
diff --git a/plane-src/apps/web/core/components/issues/peek-overview/view.tsx b/plane-src/apps/web/core/components/issues/peek-overview/view.tsx index 5766e87..1a349bb 100644 --- a/plane-src/apps/web/core/components/issues/peek-overview/view.tsx +++ b/plane-src/apps/web/core/components/issues/peek-overview/view.tsx @@ -121,7 +121,6 @@ export const IssueView = observer(function IssueView(props: IIssueView) { const issue = getIssueById(issueId); const shouldUseInteractiveEmbeddedLayout = embedIssue && interactiveEmbeddedLayout; const shouldRenderPeekSurface = !embedIssue || shouldUseInteractiveEmbeddedLayout; - const shouldAllowPeekModeToggle = !embedIssue || shouldUseInteractiveEmbeddedLayout; const shouldAllowPeekResize = !embedIssue || shouldUseInteractiveEmbeddedLayout; // remove peek id const removeRoutePeekId = () => { @@ -371,8 +370,8 @@ export const IssueView = observer(function IssueView(props: IIssueView) { !shouldUseBeamPeekShell && { "nodedc-peek-side-bound top-[5.35rem] right-3 w-[calc(100%-1.5rem)] rounded-[28px] border md:max-w-[calc(100vw-1.5rem)] md:min-w-[640px]": peekMode === "side-peek", - "top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal", - "absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen", + "nodedc-peek-modal-bound rounded-[28px]": peekMode === "modal", + "nodedc-peek-full-bound rounded-[28px]": peekMode === "full-screen", }, !embedIssue && shouldUseBeamPeekShell && @@ -448,7 +447,6 @@ export const IssueView = observer(function IssueView(props: IIssueView) { isSubmitting={isSubmitting} disabled={disabled} embedIssue={embedIssue} - showLayoutSwitcher={shouldAllowPeekModeToggle} /> )} {/* content */} diff --git a/plane-src/apps/web/styles/globals.css b/plane-src/apps/web/styles/globals.css index 607b87e..e832b48 100644 --- a/plane-src/apps/web/styles/globals.css +++ b/plane-src/apps/web/styles/globals.css @@ -386,6 +386,31 @@ bottom: var(--nodedc-peek-bottom-offset) !important; } + .nodedc-peek-modal-bound { + position: fixed !important; + top: 5.35rem !important; + bottom: var(--nodedc-peek-bottom-offset) !important; + left: 50% !important; + right: auto !important; + width: min(76rem, calc(100vw - 3rem)) !important; + max-width: calc(100vw - 3rem) !important; + height: auto !important; + min-height: 0 !important; + transform: translateX(-50%); + } + + .nodedc-peek-full-bound { + position: fixed !important; + top: 5.35rem !important; + right: 0.75rem !important; + bottom: var(--nodedc-peek-bottom-offset) !important; + left: 0.75rem !important; + width: auto !important; + max-width: none !important; + height: auto !important; + min-height: 0 !important; + } + .nodedc-beam-peek-shell[data-fullscreen="true"] { grid-template-columns: minmax(0, 1fr) 0; column-gap: 0; @@ -5806,6 +5831,23 @@ color: var(--text-color-primary) !important; } + .nodedc-attachments-panel[data-view-mode="list"] { + padding-bottom: 0.75rem; + } + + .nodedc-attachments-list-scroll { + min-height: 13.5rem; + max-height: min(34rem, calc(100vh - 20rem)); + resize: vertical; + scrollbar-gutter: stable; + } + + .nodedc-attachments-list-scroll::-webkit-resizer { + background: + linear-gradient(135deg, transparent 0 45%, rgba(var(--nodedc-accent-rgb), 0.65) 46% 54%, transparent 55%), + linear-gradient(135deg, transparent 0 62%, rgba(255, 255, 255, 0.22) 63% 70%, transparent 71%); + } + .nodedc-processing-loader, .nodedc-voice-task-processing-loader { --nodedc-processing-loader-rgb: var(--nodedc-accent-rgb);