UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: зона вложений в карточке деталей

This commit is contained in:
DCCONSTRUCTIONS 2026-04-25 10:34:37 +03:00
parent 21a9d2b809
commit 5f2d543cab
5 changed files with 248 additions and 119 deletions

View File

@ -59,7 +59,8 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
// file size // file size
const { maxFileSize } = useFileSize(); const { maxFileSize } = useFileSize();
// derived values // derived values
const issueAttachments = getAttachmentsByIssueId(issueId); const issueAttachments = getAttachmentsByIssueId(issueId) ?? [];
const hasAttachmentRows = issueAttachments.length > 0 || !!uploadStatus?.length;
// handlers // handlers
const handleFetchPropertyActivities = useCallback(() => { const handleFetchPropertyActivities = useCallback(() => {
@ -68,80 +69,96 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
const onDrop = useCallback( const onDrop = useCallback(
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => { (acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length; if (acceptedFiles.length > 0) {
if (!workspaceSlug) return;
if (rejectedFiles.length === 0) {
const currentFile: File = acceptedFiles[0];
if (!currentFile || !workspaceSlug) return;
setIsUploading(true); setIsUploading(true);
createAttachment(currentFile)
.catch(() => { Promise.allSettled(acceptedFiles.map((file) => createAttachment(file)))
setToast({ .then((results) => {
type: TOAST_TYPE.ERROR, const failedUploads = results.filter((result) => result.status === "rejected").length;
title: t("toast.error"), if (failedUploads > 0)
message: t("attachment.error"), setToast({
}); type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("attachment.error"),
});
return undefined;
}) })
.finally(() => { .finally(() => {
handleFetchPropertyActivities(); handleFetchPropertyActivities();
setIsUploading(false); setIsUploading(false);
}); });
return;
} }
setToast({ if (rejectedFiles.length > 0)
type: TOAST_TYPE.ERROR, setToast({
title: t("toast.error"), type: TOAST_TYPE.ERROR,
message: title: t("toast.error"),
totalAttachedFiles > 1 message: t("attachment.file_size_limit", { size: maxFileSize / 1024 / 1024 }),
? t("attachment.only_one_file_allowed") });
: t("attachment.file_size_limit", { size: maxFileSize / 1024 / 1024 }),
});
return; return;
}, },
[createAttachment, maxFileSize, workspaceSlug, handleFetchPropertyActivities] [createAttachment, maxFileSize, workspaceSlug, handleFetchPropertyActivities, t]
); );
const { getRootProps, getInputProps, isDragActive } = useDropzone({ const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({
onDrop, onDrop,
maxSize: maxFileSize, maxSize: maxFileSize,
multiple: false, multiple: true,
disabled: isUploading || disabled, disabled: isUploading || disabled,
}); });
return ( return (
<> <div className="space-y-3">
{uploadStatus?.map((uploadStatus) => ( {attachmentDeleteModalId && (
<IssueAttachmentsUploadItem key={uploadStatus.id} uploadStatus={uploadStatus} /> <IssueAttachmentDeleteModal
))} isOpen={Boolean(attachmentDeleteModalId)}
{issueAttachments && ( onClose={() => toggleDeleteAttachmentModal(null)}
<> attachmentOperations={attachmentOperations}
{attachmentDeleteModalId && ( attachmentId={attachmentDeleteModalId}
<IssueAttachmentDeleteModal issueServiceType={issueServiceType}
isOpen={Boolean(attachmentDeleteModalId)} />
onClose={() => toggleDeleteAttachmentModal(null)} )}
attachmentOperations={attachmentOperations}
attachmentId={attachmentDeleteModalId} <div
issueServiceType={issueServiceType} {...getRootProps()}
/> data-drag-active={isDragActive ? "true" : "false"}
)} data-drag-reject={isDragReject ? "true" : "false"}
<div className={`nodedc-attachment-upload flex w-full items-center justify-between gap-4 px-5 py-4 ${
{...getRootProps()} disabled ? "cursor-not-allowed opacity-70" : "cursor-pointer"
className={`relative flex flex-col ${isDragActive && issueAttachments.length < 3 ? "min-h-[200px]" : ""} ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`} }`}
> >
<input {...getInputProps()} /> <input {...getInputProps()} />
{isDragActive && ( <div className="flex min-w-0 items-center gap-4">
<div className="absolute top-0 left-0 z-30 flex h-full w-full items-center justify-center bg-surface-2/75"> <div className="grid size-11 flex-shrink-0 place-items-center rounded-2xl bg-[rgba(var(--nodedc-accent-rgb),0.14)] text-[rgb(var(--nodedc-accent-rgb))]">
<div className="flex items-center justify-center rounded-md bg-surface-1 p-1"> <UploadCloud className="size-5" />
<div className="flex flex-col items-center justify-center rounded-md border border-dashed border-strong px-5 py-6"> </div>
<UploadCloud className="size-7" /> <div className="min-w-0">
<span className="text-13 text-tertiary">{t("attachment.drag_and_drop")}</span> <div className="truncate text-14 font-semibold text-primary">
</div> {isDragActive ? "Отпустите файлы здесь" : "Перетащите файлы сюда или нажмите для выбора"}
</div> </div>
</div> <div className="mt-1 truncate text-12 text-tertiary">
)} Любой формат, несколько файлов за раз, до {maxFileSize / 1024 / 1024} МБ на файл
{issueAttachments?.map((attachmentId) => ( </div>
</div>
</div>
<div className="hidden flex-shrink-0 rounded-full bg-[rgba(var(--nodedc-accent-rgb),0.12)] px-3 py-1 text-12 font-semibold text-[rgb(var(--nodedc-accent-rgb))] sm:block">
Выбрать
</div>
</div>
{hasAttachmentRows && (
<div className="rounded-[1.35rem] border border-subtle bg-surface-1/60 p-3 shadow-[0_12px_28px_rgba(0,0,0,0.08)]">
<div className="mb-2 flex items-center justify-between gap-3 px-1">
<div className="text-12 font-semibold uppercase tracking-[0.08em] text-tertiary">Вложения</div>
<div className="text-12 text-tertiary">{issueAttachments.length}</div>
</div>
<div className="flex gap-3 overflow-x-auto pb-1">
{uploadStatus?.map((status) => (
<IssueAttachmentsUploadItem key={status.id} uploadStatus={status} />
))}
{issueAttachments.map((attachmentId) => (
<IssueAttachmentsListItem <IssueAttachmentsListItem
key={attachmentId} key={attachmentId}
attachmentId={attachmentId} attachmentId={attachmentId}
@ -150,8 +167,8 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
/> />
))} ))}
</div> </div>
</> </div>
)} )}
</> </div>
); );
}); });

View File

@ -5,6 +5,7 @@
*/ */
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { 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";
@ -14,8 +15,9 @@ import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types"; import { EIssueServiceType } from "@plane/types";
// ui // ui
import type { TContextMenuItem } from "@plane/ui"; import type { TContextMenuItem } from "@plane/ui";
import { ActionDropdown } 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, FileText, ImageIcon, Play, X } from "lucide-react";
// components // components
// //
import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
@ -32,6 +34,18 @@ type TIssueAttachmentsListItem = {
issueServiceType?: TIssueServiceType; issueServiceType?: TIssueServiceType;
}; };
const IMAGE_EXTENSIONS = new Set(["apng", "avif", "bmp", "gif", "jpg", "jpeg", "png", "svg", "webp"]);
const VIDEO_EXTENSIONS = new Set(["avi", "m4v", "mov", "mp4", "mpeg", "mpg", "ogv", "webm"]);
const PDF_EXTENSIONS = new Set(["pdf"]);
const getPreviewType = (extension: string) => {
const normalizedExtension = extension.toLowerCase();
if (IMAGE_EXTENSIONS.has(normalizedExtension)) return "image";
if (VIDEO_EXTENSIONS.has(normalizedExtension)) return "video";
if (PDF_EXTENSIONS.has(normalizedExtension)) return "pdf";
return "file";
};
export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) { export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) {
const { t } = useTranslation(); const { t } = useTranslation();
// props // props
@ -46,8 +60,11 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined; const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined;
const fileName = getFileName(attachment?.attributes.name ?? ""); const fileName = getFileName(attachment?.attributes.name ?? "");
const fileExtension = getFileExtension(attachment?.attributes.name ?? ""); const fileExtension = getFileExtension(attachment?.attributes.name ?? "");
const fileIcon = getFileIcon(fileExtension, 18); const fullFileName = fileExtension ? `${fileName}.${fileExtension}` : fileName;
const previewType = getPreviewType(fileExtension);
const fileIcon = getFileIcon(fileExtension, 32);
const fileURL = getFileURL(attachment?.asset_url ?? ""); const fileURL = getFileURL(attachment?.asset_url ?? "");
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const menuItems: TContextMenuItem[] = [ const menuItems: TContextMenuItem[] = [
{ {
key: "delete", key: "delete",
@ -64,56 +81,135 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
if (!attachment) return <></>; if (!attachment) return <></>;
return ( return (
<div <>
role="button" <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">
tabIndex={0} <button
onClick={(e) => { type="button"
e.preventDefault(); className="flex min-w-0 flex-1 items-stretch text-left"
e.stopPropagation(); onClick={(e) => {
window.open(fileURL, "_blank"); e.preventDefault();
}} e.stopPropagation();
onKeyDown={(e) => { setIsPreviewOpen(true);
if (e.key === "Enter" || e.key === " ") { }}
e.preventDefault(); >
e.stopPropagation(); <div className="relative h-full w-28 flex-shrink-0 overflow-hidden bg-surface-1">
window.open(fileURL, "_blank"); {previewType === "image" && fileURL ? (
} <img src={fileURL} alt={fullFileName} className="h-full w-full object-cover" loading="lazy" />
}} ) : previewType === "video" && fileURL ? (
>
<div className="group flex h-11 items-center justify-between gap-3 pr-2 pl-9 hover:bg-surface-2">
<div className="flex items-center gap-3 truncate text-13">
<div className="flex items-center gap-3">{fileIcon}</div>
<Tooltip tooltipContent={`${fileName}.${fileExtension}`} isMobile={isMobile}>
<p className="truncate font-medium text-secondary">{`${fileName}.${fileExtension}`}</p>
</Tooltip>
<span className="flex size-1.5 rounded-full bg-layer-1" />
<span className="flex-shrink-0 text-placeholder">{convertBytesToSize(attachment.attributes.size)}</span>
</div>
<div className="flex items-center gap-3">
{attachment?.created_by && (
<> <>
<Tooltip <video src={fileURL} className="h-full w-full object-cover" muted playsInline preload="metadata" />
isMobile={isMobile} <div className="absolute inset-0 grid place-items-center bg-black/20 text-white">
tooltipContent={`${ <Play className="size-6 fill-current" />
getUserDetails(attachment?.created_by)?.display_name ?? "" </div>
} uploaded on ${renderFormattedDate(attachment.updated_at)}`}
>
<div className="flex items-center justify-center">
<ButtonAvatars showTooltip userIds={attachment?.created_by} />
</div>
</Tooltip>
</> </>
) : previewType === "pdf" && fileURL ? (
<div className="flex h-full w-full items-center justify-center bg-white text-red-500">
<FileText className="size-9" />
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
{previewType === "file" ? fileIcon : <ImageIcon className="size-8 text-tertiary" />}
</div>
)} )}
<ActionDropdown
items={menuItems}
buttonClassName={getIconButtonStyling("ghost", "sm")}
placement="bottom-end"
disabled={!!disabled}
/>
</div> </div>
<div className="flex min-w-0 flex-1 flex-col justify-between p-3">
<div className="min-w-0">
<Tooltip tooltipContent={fullFileName} isMobile={isMobile}>
<p className="line-clamp-2 text-13 font-semibold text-secondary">{fullFileName}</p>
</Tooltip>
<div className="mt-2 flex items-center gap-2 text-11 text-tertiary">
<span>{fileExtension ? fileExtension.toUpperCase() : "FILE"}</span>
<span className="size-1 rounded-full bg-layer-1" />
<span>{convertBytesToSize(attachment.attributes.size)}</span>
</div>
</div>
{attachment?.created_by && (
<Tooltip
isMobile={isMobile}
tooltipContent={`${
getUserDetails(attachment?.created_by)?.display_name ?? ""
} uploaded on ${renderFormattedDate(attachment.updated_at)}`}
>
<div className="flex w-fit items-center justify-center">
<ButtonAvatars showTooltip userIds={attachment?.created_by} />
</div>
</Tooltip>
)}
</div>
</button>
<div className="flex w-10 flex-shrink-0 items-start justify-center pt-2">
<ActionDropdown
items={menuItems}
buttonClassName={getIconButtonStyling("ghost", "sm")}
placement="bottom-end"
disabled={!!disabled}
/>
</div>
</div> </div>
</div>
<ModalCore
isOpen={isPreviewOpen}
handleClose={() => setIsPreviewOpen(false)}
position={EModalPosition.CENTER}
width={EModalWidth.VIXL}
className="overflow-hidden border border-subtle bg-surface-1"
>
<div className="relative min-h-[55vh] bg-surface-1 p-4">
<div className="absolute top-4 right-4 z-10 flex items-center gap-2">
{fileURL && (
<a
href={fileURL}
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/35 text-white backdrop-blur-md transition hover:bg-black/50"
onClick={() => setIsPreviewOpen(false)}
>
<X className="size-5" />
</button>
</div>
<div className="pr-24">
<div className="text-15 font-semibold text-primary">{fullFileName}</div>
<div className="mt-1 text-12 text-tertiary">{convertBytesToSize(attachment.attributes.size)}</div>
</div>
<div className="mt-4 flex h-[70vh] max-h-[70vh] items-center justify-center overflow-hidden rounded-2xl bg-black/20">
{previewType === "image" && fileURL ? (
<img src={fileURL} alt={fullFileName} className="max-h-full max-w-full object-contain" />
) : previewType === "video" && fileURL ? (
<video src={fileURL} className="max-h-full max-w-full" controls autoPlay>
<track kind="captions" />
</video>
) : previewType === "pdf" && fileURL ? (
<iframe
title={fullFileName}
src={fileURL}
sandbox="allow-downloads allow-same-origin"
className="h-full w-full rounded-2xl bg-white"
/>
) : (
<div className="flex flex-col items-center gap-4 p-8 text-center">
<div className="grid size-20 place-items-center rounded-3xl bg-surface-2">{fileIcon}</div>
<div className="max-w-md text-14 text-secondary">
Предпросмотр для этого формата недоступен. Файл можно скачать или открыть в новой вкладке.
</div>
</div>
)}
</div>
</div>
</ModalCore>
</>
); );
}); });

View File

@ -32,18 +32,21 @@ export const IssueAttachmentsUploadItem = observer(function IssueAttachmentsUplo
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
return ( return (
<div className="pointer-events-none flex h-11 items-center justify-between gap-3 bg-surface-2 pr-2 pl-9"> <div className="pointer-events-none flex h-24 w-64 flex-shrink-0 items-center justify-between gap-3 rounded-2xl border border-subtle bg-surface-2 px-3 py-2">
<div className="flex items-center gap-3 truncate text-13"> <div className="flex min-w-0 items-center gap-3 text-13">
<div className="flex-shrink-0">{fileIcon}</div> <div className="grid size-12 flex-shrink-0 place-items-center rounded-xl bg-surface-1">{fileIcon}</div>
<Tooltip tooltipContent={fileName} isMobile={isMobile}> <div className="min-w-0">
<p className="truncate font-medium text-secondary">{fileName}</p> <Tooltip tooltipContent={fileName} isMobile={isMobile}>
</Tooltip> <p className="truncate font-medium text-secondary">{fileName}</p>
</Tooltip>
<p className="mt-1 text-11 text-tertiary">Загрузка</p>
</div>
</div> </div>
<div className="flex flex-shrink-0 items-center gap-2"> <div className="flex flex-shrink-0 flex-col items-center gap-1">
<span className="flex-shrink-0"> <span className="flex-shrink-0">
<CircularProgressIndicator size={20} strokeWidth={3} percentage={uploadStatus.progress} /> <CircularProgressIndicator size={20} strokeWidth={3} percentage={uploadStatus.progress} />
</span> </span>
<div className="flex-shrink-0 text-13 font-medium">{uploadStatus.progress}% done</div> <div className="flex-shrink-0 text-11 font-medium text-secondary">{uploadStatus.progress}%</div>
</div> </div>
</div> </div>
); );

View File

@ -49,8 +49,8 @@ export const IssueDetailWidgetCollapsibles = observer(function IssueDetailWidget
const attachmentUploads = getAttachmentsUploadStatusByIssueId(issueId); const attachmentUploads = getAttachmentsUploadStatusByIssueId(issueId);
const attachmentsCount = getAttachmentsCountByIssueId(issueId); const attachmentsCount = getAttachmentsCountByIssueId(issueId);
const shouldRenderAttachments = const shouldRenderAttachments =
attachmentsCount > 0 || !hideWidgets?.includes("attachments") &&
(!!attachmentUploads && attachmentUploads.length > 0 && !hideWidgets?.includes("attachments")); (attachmentsCount > 0 || (!!attachmentUploads && attachmentUploads.length > 0));
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">

View File

@ -8,6 +8,7 @@ import React from "react";
// plane imports // plane imports
import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types";
// local imports // local imports
import { IssueAttachmentsCollapsibleContent } from "./attachments/content";
import { IssueDetailWidgetActionButtons } from "./action-buttons"; import { IssueDetailWidgetActionButtons } from "./action-buttons";
import { IssueDetailWidgetCollapsibles } from "./issue-detail-widget-collapsibles"; import { IssueDetailWidgetCollapsibles } from "./issue-detail-widget-collapsibles";
import { IssueDetailWidgetModals } from "./issue-detail-widget-modals"; import { IssueDetailWidgetModals } from "./issue-detail-widget-modals";
@ -34,6 +35,9 @@ export function IssueDetailWidgets(props: Props) {
hideWidgets, hideWidgets,
compactView = false, compactView = false,
} = props; } = props;
const hideWidgetsWithInlineAttachments = hideWidgets?.includes("attachments")
? hideWidgets
: ([...(hideWidgets ?? []), "attachments"] as TWorkItemWidgets[]);
return ( return (
<> <>
@ -44,16 +48,25 @@ export function IssueDetailWidgets(props: Props) {
issueId={issueId} issueId={issueId}
disabled={disabled} disabled={disabled}
issueServiceType={issueServiceType} issueServiceType={issueServiceType}
hideWidgets={hideWidgets} hideWidgets={hideWidgetsWithInlineAttachments}
compactView={compactView} compactView={compactView}
/> />
{!hideWidgets?.includes("attachments") && (
<IssueAttachmentsCollapsibleContent
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
)}
<IssueDetailWidgetCollapsibles <IssueDetailWidgetCollapsibles
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
issueId={issueId} issueId={issueId}
disabled={disabled} disabled={disabled}
issueServiceType={issueServiceType} issueServiceType={issueServiceType}
hideWidgets={hideWidgets} hideWidgets={hideWidgetsWithInlineAttachments}
/> />
</div> </div>
{renderWidgetModals && ( {renderWidgetModals && (