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

View File

@ -5,6 +5,7 @@
*/
import { observer } from "mobx-react";
import { useState } from "react";
import { useTranslation } from "@plane/i18n";
import { getIconButtonStyling } from "@plane/propel/icon-button";
@ -14,8 +15,9 @@ import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// 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 { Download, FileText, ImageIcon, Play, X } from "lucide-react";
// components
//
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
@ -32,6 +34,18 @@ type TIssueAttachmentsListItem = {
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) {
const { t } = useTranslation();
// props
@ -46,8 +60,11 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined;
const fileName = getFileName(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 [isPreviewOpen, setIsPreviewOpen] = useState(false);
const menuItems: TContextMenuItem[] = [
{
key: "delete",
@ -64,48 +81,66 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
if (!attachment) return <></>;
return (
<div
role="button"
tabIndex={0}
<>
<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"
className="flex min-w-0 flex-1 items-stretch text-left"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(fileURL, "_blank");
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
window.open(fileURL, "_blank");
}
setIsPreviewOpen(true);
}}
>
<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 className="relative h-full w-28 flex-shrink-0 overflow-hidden bg-surface-1">
{previewType === "image" && fileURL ? (
<img src={fileURL} alt={fullFileName} className="h-full w-full object-cover" loading="lazy" />
) : previewType === "video" && fileURL ? (
<>
<video src={fileURL} className="h-full w-full object-cover" muted playsInline preload="metadata" />
<div className="absolute inset-0 grid place-items-center bg-black/20 text-white">
<Play className="size-6 fill-current" />
</div>
</>
) : 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>
)}
</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>
<div className="flex items-center gap-3">
{attachment?.created_by && (
<>
<Tooltip
isMobile={isMobile}
tooltipContent={`${
getUserDetails(attachment?.created_by)?.display_name ?? ""
} uploaded on ${renderFormattedDate(attachment.updated_at)}`}
>
<div className="flex items-center justify-center">
<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")}
@ -114,6 +149,67 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
/>
</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();
return (
<div className="pointer-events-none flex h-11 items-center justify-between gap-3 bg-surface-2 pr-2 pl-9">
<div className="flex items-center gap-3 truncate text-13">
<div className="flex-shrink-0">{fileIcon}</div>
<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 min-w-0 items-center gap-3 text-13">
<div className="grid size-12 flex-shrink-0 place-items-center rounded-xl bg-surface-1">{fileIcon}</div>
<div className="min-w-0">
<Tooltip tooltipContent={fileName} isMobile={isMobile}>
<p className="truncate font-medium text-secondary">{fileName}</p>
</Tooltip>
<p className="mt-1 text-11 text-tertiary">Загрузка</p>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
</div>
<div className="flex flex-shrink-0 flex-col items-center gap-1">
<span className="flex-shrink-0">
<CircularProgressIndicator size={20} strokeWidth={3} percentage={uploadStatus.progress} />
</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>
);

View File

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

View File

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