Initial commit: Plane
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled

Synced from upstream: 8853637e981ed7d8a6cff32bd98e7afe20f54362
This commit is contained in:
chuan
2025-11-07 00:00:52 +08:00
commit 8ebde8aa05
4886 changed files with 462270 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
"use client";
import type { FC } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { AlertCircle } from "lucide-react";
import { CloseIcon } from "@plane/propel/icons";
// ui
import { Tooltip } from "@plane/propel/tooltip";
import {
convertBytesToSize,
getFileExtension,
getFileName,
getFileURL,
renderFormattedDate,
truncateText,
} from "@plane/utils";
// icons
//
import { getFileIcon } from "@/components/icons";
// components
import { IssueAttachmentDeleteModal } from "@/components/issues/attachment/delete-attachment-modal";
// helpers
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
import { usePlatformOS } from "@/hooks/use-platform-os";
// types
import type { TAttachmentHelpers } from "../issue-detail-widgets/attachments/helper";
type TAttachmentOperationsRemoveModal = Exclude<TAttachmentHelpers, "create">;
type TIssueAttachmentsDetail = {
attachmentId: string;
attachmentHelpers: TAttachmentOperationsRemoveModal;
disabled?: boolean;
};
export const IssueAttachmentsDetail: FC<TIssueAttachmentsDetail> = observer((props) => {
// props
const { attachmentId, attachmentHelpers, disabled } = props;
// store hooks
const { getUserDetails } = useMember();
const {
attachment: { getAttachmentById },
} = useIssueDetail();
// state
const [isDeleteIssueAttachmentModalOpen, setIsDeleteIssueAttachmentModalOpen] = useState(false);
// derived values
const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined;
const fileName = getFileName(attachment?.attributes.name ?? "");
const fileExtension = getFileExtension(attachment?.asset_url ?? "");
const fileIcon = getFileIcon(fileExtension, 28);
const fileURL = getFileURL(attachment?.asset_url ?? "");
// hooks
const { isMobile } = usePlatformOS();
if (!attachment) return <></>;
return (
<>
{isDeleteIssueAttachmentModalOpen && (
<IssueAttachmentDeleteModal
isOpen={isDeleteIssueAttachmentModalOpen}
onClose={() => setIsDeleteIssueAttachmentModalOpen(false)}
attachmentOperations={attachmentHelpers.operations}
attachmentId={attachmentId}
/>
)}
<div className="flex h-[60px] items-center justify-between gap-1 rounded-md border-[2px] border-custom-border-200 bg-custom-background-100 px-4 py-2 text-sm">
<Link href={fileURL ?? ""} target="_blank" rel="noopener noreferrer">
<div className="flex items-center gap-3">
<div className="h-7 w-7">{fileIcon}</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Tooltip tooltipContent={fileName} isMobile={isMobile}>
<span className="text-sm">{truncateText(`${fileName}`, 10)}</span>
</Tooltip>
<Tooltip
isMobile={isMobile}
tooltipContent={`${
getUserDetails(attachment.updated_by)?.display_name ?? ""
} uploaded on ${renderFormattedDate(attachment.updated_at)}`}
>
<span>
<AlertCircle className="h-3 w-3" />
</span>
</Tooltip>
</div>
<div className="flex items-center gap-3 text-xs text-custom-text-200">
<span>{fileExtension.toUpperCase()}</span>
<span>{convertBytesToSize(attachment.attributes.size)}</span>
</div>
</div>
</div>
</Link>
{!disabled && (
<button type="button" onClick={() => setIsDeleteIssueAttachmentModalOpen(true)}>
<CloseIcon className="h-4 w-4 text-custom-text-200 hover:text-custom-text-100" />
</button>
)}
</div>
</>
);
});

View File

@@ -0,0 +1,152 @@
import type { FC } from "react";
import { useCallback, useState } from "react";
import { observer } from "mobx-react";
import type { FileRejection } from "react-dropzone";
import { useDropzone } from "react-dropzone";
import { UploadCloud } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// plane web hooks
import { useFileSize } from "@/plane-web/hooks/use-file-size";
// types
import type { TAttachmentHelpers } from "../issue-detail-widgets/attachments/helper";
// components
import { IssueAttachmentsListItem } from "./attachment-list-item";
import { IssueAttachmentsUploadItem } from "./attachment-list-upload-item";
// types
import { IssueAttachmentDeleteModal } from "./delete-attachment-modal";
type TIssueAttachmentItemList = {
workspaceSlug: string;
projectId: string;
issueId: string;
attachmentHelpers: TAttachmentHelpers;
disabled?: boolean;
issueServiceType?: TIssueServiceType;
};
export const IssueAttachmentItemList: FC<TIssueAttachmentItemList> = observer((props) => {
const {
workspaceSlug,
projectId,
issueId,
attachmentHelpers,
disabled,
issueServiceType = EIssueServiceType.ISSUES,
} = props;
const { t } = useTranslation();
// states
const [isUploading, setIsUploading] = useState(false);
// store hooks
const {
attachment: { getAttachmentsByIssueId },
attachmentDeleteModalId,
toggleDeleteAttachmentModal,
fetchActivities,
} = useIssueDetail(issueServiceType);
const { operations: attachmentOperations, snapshot: attachmentSnapshot } = attachmentHelpers;
const { create: createAttachment } = attachmentOperations;
const { uploadStatus } = attachmentSnapshot;
// file size
const { maxFileSize } = useFileSize();
// derived values
const issueAttachments = getAttachmentsByIssueId(issueId);
// handlers
const handleFetchPropertyActivities = useCallback(() => {
fetchActivities(workspaceSlug, projectId, issueId);
}, [fetchActivities, workspaceSlug, projectId, issueId]);
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;
setIsUploading(true);
createAttachment(currentFile)
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: t("attachment.error"),
});
})
.finally(() => {
handleFetchPropertyActivities();
setIsUploading(false);
});
return;
}
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 }),
});
return;
},
[createAttachment, maxFileSize, workspaceSlug, handleFetchPropertyActivities]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
maxSize: maxFileSize,
multiple: false,
disabled: isUploading || disabled,
});
return (
<>
{uploadStatus?.map((uploadStatus) => (
<IssueAttachmentsUploadItem key={uploadStatus.id} uploadStatus={uploadStatus} />
))}
{issueAttachments && (
<>
{attachmentDeleteModalId && (
<IssueAttachmentDeleteModal
isOpen={Boolean(attachmentDeleteModalId)}
onClose={() => toggleDeleteAttachmentModal(null)}
attachmentOperations={attachmentOperations}
attachmentId={attachmentDeleteModalId}
issueServiceType={issueServiceType}
/>
)}
<div
{...getRootProps()}
className={`relative flex flex-col ${isDragActive && issueAttachments.length < 3 ? "min-h-[200px]" : ""} ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
>
<input {...getInputProps()} />
{isDragActive && (
<div className="absolute flex items-center justify-center left-0 top-0 h-full w-full bg-custom-background-90/75 z-30 ">
<div className="flex items-center justify-center p-1 rounded-md bg-custom-background-100">
<div className="flex flex-col justify-center items-center px-5 py-6 rounded-md border border-dashed border-custom-border-300">
<UploadCloud className="size-7" />
<span className="text-sm text-custom-text-300">{t("attachment.drag_and_drop")}</span>
</div>
</div>
</div>
)}
{issueAttachments?.map((attachmentId) => (
<IssueAttachmentsListItem
key={attachmentId}
attachmentId={attachmentId}
disabled={disabled}
issueServiceType={issueServiceType}
/>
))}
</div>
</>
)}
</>
);
});

View File

@@ -0,0 +1,102 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
import { Trash } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@plane/propel/tooltip";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// ui
import { CustomMenu } from "@plane/ui";
import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils";
// components
//
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { getFileIcon } from "@/components/icons";
// helpers
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
import { usePlatformOS } from "@/hooks/use-platform-os";
type TIssueAttachmentsListItem = {
attachmentId: string;
disabled?: boolean;
issueServiceType?: TIssueServiceType;
};
export const IssueAttachmentsListItem: FC<TIssueAttachmentsListItem> = observer((props) => {
const { t } = useTranslation();
// props
const { attachmentId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
// store hooks
const { getUserDetails } = useMember();
const {
attachment: { getAttachmentById },
toggleDeleteAttachmentModal,
} = useIssueDetail(issueServiceType);
// derived values
const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined;
const fileName = getFileName(attachment?.attributes.name ?? "");
const fileExtension = getFileExtension(attachment?.attributes.name ?? "");
const fileIcon = getFileIcon(fileExtension, 18);
const fileURL = getFileURL(attachment?.asset_url ?? "");
// hooks
const { isMobile } = usePlatformOS();
if (!attachment) return <></>;
return (
<>
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(fileURL, "_blank");
}}
>
<div className="group flex items-center justify-between gap-3 h-11 hover:bg-custom-background-90 pl-9 pr-2">
<div className="flex items-center gap-3 text-sm truncate">
<div className="flex items-center gap-3">{fileIcon}</div>
<Tooltip tooltipContent={`${fileName}.${fileExtension}`} isMobile={isMobile}>
<p className="text-custom-text-200 font-medium truncate">{`${fileName}.${fileExtension}`}</p>
</Tooltip>
<span className="flex size-1.5 bg-custom-background-80 rounded-full" />
<span className="flex-shrink-0 text-custom-text-400">{convertBytesToSize(attachment.attributes.size)}</span>
</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">
<ButtonAvatars showTooltip userIds={attachment?.created_by} />
</div>
</Tooltip>
</>
)}
<CustomMenu ellipsis closeOnSelect placement="bottom-end" disabled={disabled}>
<CustomMenu.MenuItem
onClick={() => {
toggleDeleteAttachmentModal(attachmentId);
}}
>
<div className="flex items-center gap-2">
<Trash className="h-3.5 w-3.5" strokeWidth={2} />
<span>{t("common.actions.delete")}</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</button>
</>
);
});

View File

@@ -0,0 +1,46 @@
"use client";
import { observer } from "mobx-react";
// ui
import { Tooltip } from "@plane/propel/tooltip";
import { CircularProgressIndicator } from "@plane/ui";
// components
import { getFileExtension } from "@plane/utils";
import { getFileIcon } from "@/components/icons";
// helpers
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
// types
import type { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store";
type Props = {
uploadStatus: TAttachmentUploadStatus;
};
export const IssueAttachmentsUploadItem: React.FC<Props> = observer((props) => {
// props
const { uploadStatus } = props;
// derived values
const fileName = uploadStatus.name;
const fileExtension = getFileExtension(uploadStatus.name ?? "");
const fileIcon = getFileIcon(fileExtension, 18);
// hooks
const { isMobile } = usePlatformOS();
return (
<div className="flex items-center justify-between gap-3 h-11 bg-custom-background-90 pl-9 pr-2 pointer-events-none">
<div className="flex items-center gap-3 text-sm truncate">
<div className="flex-shrink-0">{fileIcon}</div>
<Tooltip tooltipContent={fileName} isMobile={isMobile}>
<p className="text-custom-text-200 font-medium truncate">{fileName}</p>
</Tooltip>
</div>
<div className="flex-shrink-0 flex items-center gap-2">
<span className="flex-shrink-0">
<CircularProgressIndicator size={20} strokeWidth={3} percentage={uploadStatus.progress} />
</span>
<div className="flex-shrink-0 text-sm font-medium">{uploadStatus.progress}% done</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,54 @@
"use client";
import { observer } from "mobx-react";
import { Tooltip } from "@plane/propel/tooltip";
import { CircularProgressIndicator } from "@plane/ui";
import { getFileExtension, truncateText } from "@plane/utils";
// ui
// icons
import { getFileIcon } from "@/components/icons";
// helpers
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
// types
import type { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store";
type Props = {
uploadStatus: TAttachmentUploadStatus;
};
export const IssueAttachmentsUploadDetails: React.FC<Props> = observer((props) => {
// props
const { uploadStatus } = props;
// derived values
const fileName = uploadStatus.name;
const fileExtension = getFileExtension(uploadStatus.name ?? "");
const fileIcon = getFileIcon(fileExtension, 28);
// hooks
const { isMobile } = usePlatformOS();
return (
<div className="flex h-[60px] items-center justify-between gap-1 rounded-md border-[2px] border-custom-border-200 bg-custom-background-90 px-4 py-2 text-sm pointer-events-none">
<div className="flex-shrink-0 flex items-center gap-3">
<div className="h-7 w-7">{fileIcon}</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Tooltip tooltipContent={fileName} isMobile={isMobile}>
<span className="text-sm">{truncateText(`${fileName}`, 10)}</span>
</Tooltip>
</div>
<div className="flex items-center gap-3 text-xs text-custom-text-200">
<span>{fileExtension.toUpperCase()}</span>
</div>
</div>
</div>
<div className="flex-shrink-0 flex items-center gap-2">
<span className="flex-shrink-0">
<CircularProgressIndicator size={20} strokeWidth={3} percentage={uploadStatus.progress} />
</span>
<div className="flex-shrink-0 text-sm font-medium">{uploadStatus.progress}% done</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,66 @@
import { useCallback, useState } from "react";
import { observer } from "mobx-react";
import { useDropzone } from "react-dropzone";
// plane web hooks
import { useFileSize } from "@/plane-web/hooks/use-file-size";
// types
import type { TAttachmentOperations } from "../issue-detail-widgets/attachments/helper";
type TAttachmentOperationsModal = Pick<TAttachmentOperations, "create">;
type Props = {
workspaceSlug: string;
disabled?: boolean;
attachmentOperations: TAttachmentOperationsModal;
};
export const IssueAttachmentUpload: React.FC<Props> = observer((props) => {
const { workspaceSlug, disabled = false, attachmentOperations } = props;
// states
const [isLoading, setIsLoading] = useState(false);
// file size
const { maxFileSize } = useFileSize();
const onDrop = useCallback(
(acceptedFiles: File[]) => {
const currentFile: File = acceptedFiles[0];
if (!currentFile || !workspaceSlug) return;
setIsLoading(true);
attachmentOperations.create(currentFile).finally(() => setIsLoading(false));
},
[attachmentOperations, workspaceSlug]
);
const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({
onDrop,
maxSize: maxFileSize,
multiple: false,
disabled: isLoading || disabled,
});
const fileError =
fileRejections.length > 0 ? `Invalid file type or size (max ${maxFileSize / 1024 / 1024} MB)` : null;
return (
<div
{...getRootProps()}
className={`flex h-[60px] items-center justify-center rounded-md border-2 border-dashed bg-custom-primary/5 px-4 text-xs text-custom-primary ${
isDragActive ? "border-custom-primary bg-custom-primary/10" : "border-custom-border-200"
} ${isDragReject ? "bg-red-100" : ""} ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
>
<input {...getInputProps()} />
<span className="flex items-center gap-2">
{isDragActive ? (
<p>Drop here...</p>
) : fileError ? (
<p className="text-center text-red-500">{fileError}</p>
) : isLoading ? (
<p className="text-center">Uploading...</p>
) : (
<p className="text-center">Click or drag a file here</p>
)}
</span>
</div>
);
});

View File

@@ -0,0 +1,43 @@
import type { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// types
import type { TAttachmentHelpers } from "../issue-detail-widgets/attachments/helper";
// components
import { IssueAttachmentsDetail } from "./attachment-detail";
import { IssueAttachmentsUploadDetails } from "./attachment-upload-details";
type TIssueAttachmentsList = {
issueId: string;
attachmentHelpers: TAttachmentHelpers;
disabled?: boolean;
};
export const IssueAttachmentsList: FC<TIssueAttachmentsList> = observer((props) => {
const { issueId, attachmentHelpers, disabled } = props;
// store hooks
const {
attachment: { getAttachmentsByIssueId },
} = useIssueDetail();
// derived values
const { snapshot: attachmentSnapshot } = attachmentHelpers;
const { uploadStatus } = attachmentSnapshot;
const issueAttachments = getAttachmentsByIssueId(issueId);
return (
<>
{uploadStatus?.map((uploadStatus) => (
<IssueAttachmentsUploadDetails key={uploadStatus.id} uploadStatus={uploadStatus} />
))}
{issueAttachments?.map((attachmentId) => (
<IssueAttachmentsDetail
key={attachmentId}
attachmentId={attachmentId}
disabled={disabled}
attachmentHelpers={attachmentHelpers}
/>
))}
</>
);
});

View File

@@ -0,0 +1,71 @@
import type { FC } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
// plane-i18n
import { useTranslation } from "@plane/i18n";
// types
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// ui
import { AlertModalCore } from "@plane/ui";
// helper
import { getFileName } from "@plane/utils";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// types
import type { TAttachmentOperations } from "../issue-detail-widgets/attachments/helper";
export type TAttachmentOperationsRemoveModal = Pick<TAttachmentOperations, "remove">;
type Props = {
isOpen: boolean;
onClose: () => void;
attachmentId: string;
attachmentOperations: TAttachmentOperationsRemoveModal;
issueServiceType?: TIssueServiceType;
};
export const IssueAttachmentDeleteModal: FC<Props> = observer((props) => {
const { t } = useTranslation();
const { isOpen, onClose, attachmentId, attachmentOperations, issueServiceType = EIssueServiceType.ISSUES } = props;
// states
const [loader, setLoader] = useState(false);
// store hooks
const {
attachment: { getAttachmentById },
} = useIssueDetail(issueServiceType);
// derived values
const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined;
// handlers
const handleClose = () => {
onClose();
setLoader(false);
};
const handleDeletion = async (assetId: string) => {
setLoader(true);
attachmentOperations.remove(assetId).finally(() => handleClose());
};
if (!attachment) return <></>;
return (
<AlertModalCore
handleClose={handleClose}
handleSubmit={() => handleDeletion(attachment.id)}
isSubmitting={loader}
isOpen={isOpen}
title={t("attachment.delete")}
content={
<>
{/* TODO: Translate here */}
Are you sure you want to delete attachment-{" "}
<span className="font-bold">{getFileName(attachment.attributes.name)}</span>? This attachment will be
permanently removed. This action cannot be undone.
</>
}
/>
);
});

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,37 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// hooks
import { useAttachmentOperations } from "../issue-detail-widgets/attachments/helper";
// components
import { IssueAttachmentUpload } from "./attachment-upload";
import { IssueAttachmentsList } from "./attachments-list";
export type TIssueAttachmentRoot = {
workspaceSlug: string;
projectId: string;
issueId: string;
disabled?: boolean;
};
export const IssueAttachmentRoot: FC<TIssueAttachmentRoot> = observer((props) => {
// props
const { workspaceSlug, projectId, issueId, disabled = false } = props;
// hooks
const attachmentHelpers = useAttachmentOperations(workspaceSlug, projectId, issueId);
return (
<div className="relative py-3 space-y-3">
<h3 className="text-lg">Attachments</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<IssueAttachmentUpload
workspaceSlug={workspaceSlug}
disabled={disabled}
attachmentOperations={attachmentHelpers.operations}
/>
<IssueAttachmentsList issueId={issueId} disabled={disabled} attachmentHelpers={attachmentHelpers} />
</div>
</div>
);
});