[WEB-310] dev: private bucket implementation (#5793)

* chore: migrations and backmigration to move attachments to file asset

* chore: move attachments to file assets

* chore: update migration file to include created by and updated by and size

* chore: remove uninmport errors

* chore: make size as float field

* fix: file asset uploads

* chore: asset uploads migration changes

* chore: v2 assets endpoint

* chore: remove unused imports

* chore: issue attachments

* chore: issue attachments

* chore: workspace logo endpoints

* chore: private bucket changes

* chore: user asset endpoint

* chore: add logo_url validation

* chore: cover image urlk

* chore: change asset max length

* chore: pages endpoint

* chore: store the storage_metadata only when none

* chore: attachment asset apis

* chore: update create private bucket

* chore: make bucket private

* chore: fix response of user uploads

* fix: response of user uploads

* fix: job to fix file asset uploads

* fix: user asset endpoints

* chore: avatar for user profile

* chore: external apis user url endpoint

* chore: upload workspace and user asset actions updated

* chore: analytics endpoint

* fix: analytics export

* chore: avatar urls

* chore: update user avatar instances

* chore: avatar urls for assignees and creators

* chore: bucket permission script

* fix: all user avatr instances in the web app

* chore: update project cover image logic

* fix: issue attachment endpoint

* chore: patch endpoint for issue attachment

* chore: attachments

* chore: change attachment storage class

* chore: update issue attachment endpoints

* fix: issue attachment

* chore: update issue attachment implementation

* chore: page asset endpoints

* fix: web build errors

* chore: attachments

* chore: page asset urls

* chore: comment and issue asset endpoints

* chore: asset endpoints

* chore: attachment endpoints

* chore: bulk asset endpoint

* chore: restore endpoint

* chore: project assets endpoints

* chore: asset url

* chore: add delete asset endpoints

* chore: fix asset upload endpoint

* chore: update patch endpoints

* chore: update patch endpoint

* chore: update editor image handling

* chore: asset restore endpoints

* chore: avatar url for space assets

* chore: space app assets migration

* fix: space app urls

* chore: space endpoints

* fix: old editor images rendering logic

* fix: issue archive and attachment activity

* chore: asset deletes

* chore: attachment delete

* fix: issue attachment

* fix: issue attachment get

* chore: cover image url for projects

* chore: remove duplicate py file

* fix: url check function

* chore: chore project cover asset delete

* fix: migrations

* chore: delete migration files

* chore: update bucket

* fix: build errors

* chore: add asset url in intake attachment

* chore: project cover fix

* chore: update next.config

* chore: delete old workspace logos

* chore: workspace assets

* chore: asset get for space

* chore: update project modal

* chore: remove unused imports

* fix: space app editor helper

* chore: update rich-text read-only editor

* chore: create multiple column for entity identifiers

* chore: update migrations

* chore: remove entity identifier

* fix: issue assets

* chore: update maximum file size logic

* chore: update editor max file size logic

* fix: close modal after removing workspace logo

* chore: update uploaded asstes' status post issue creation

* chore: added file size limit to the space app

* dev: add file size limit restriction on all endpoints

* fix: remove old workspace logo and user avatar

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
This commit is contained in:
Aaryan Khandelwal 2024-10-11 20:13:38 +05:30 committed by GitHub
parent c9580ab794
commit 7e334203f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
241 changed files with 5326 additions and 2518 deletions

View file

@ -18,6 +18,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
embedHandler,
fileHandler,
forwardedRef,
handleEditorReady,
id,
@ -38,6 +39,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn
const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({
editorClassName,
extensions,
fileHandler,
forwardedRef,
handleEditorReady,
id,

View file

@ -10,7 +10,7 @@ import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// types
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig } from "@/types";
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TFileHandler } from "@/types";
interface IDocumentReadOnlyEditor {
id: string;
@ -19,6 +19,7 @@ interface IDocumentReadOnlyEditor {
displayConfig?: TDisplayConfig;
editorClassName?: string;
embedHandler: any;
fileHandler: Pick<TFileHandler, "getAssetSrc">;
tabIndex?: number;
handleEditorReady?: (value: boolean) => void;
mentionHandler: {
@ -33,6 +34,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
embedHandler,
fileHandler,
id,
forwardedRef,
handleEditorReady,
@ -51,6 +53,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
const editor = useReadOnlyEditor({
editorClassName,
extensions,
fileHandler,
forwardedRef,
handleEditorReady,
initialValue,

View file

@ -14,14 +14,16 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
containerClassName,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
fileHandler,
forwardedRef,
id,
initialValue,
forwardedRef,
mentionHandler,
} = props;
const editor = useReadOnlyEditor({
editorClassName,
fileHandler,
forwardedRef,
initialValue,
mentionHandler,

View file

@ -42,6 +42,7 @@ type CustomImageBlockProps = CustomImageNodeViewProps & {
setFailedToLoadImage: (isError: boolean) => void;
editorContainer: HTMLDivElement | null;
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
src: string;
};
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
@ -55,9 +56,10 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
getPos,
editor,
editorContainer,
src: remoteImageSrc,
setEditorContainer,
} = props;
const { src: remoteImageSrc, width, height, aspectRatio } = node.attrs;
const { width, height, aspectRatio } = node.attrs;
// states
const [size, setSize] = useState<Size>({
width: ensurePixelString(width, "35%"),
@ -206,7 +208,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
// show the image resizer only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
const showImageResizer = editor.isEditable && remoteImageSrc && initialResizeComplete;
// show the preview image from the file system if the remote image's src is not set
const displayedImageSrc = remoteImageSrc ?? imageFromFileSystem;
const displayedImageSrc = remoteImageSrc || imageFromFileSystem;
return (
<div

View file

@ -54,6 +54,8 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => {
imageFromFileSystem={imageFromFileSystem}
editorContainer={editorContainer}
editor={editor}
// @ts-expect-error function not expected here, but will still work
src={editor?.commands?.getImageSource?.(node.attrs.src)}
getPos={getPos}
node={node}
setEditorContainer={setEditorContainer}
@ -67,6 +69,7 @@ export const CustomImageNode = (props: CustomImageNodeViewProps) => {
failedToLoadImage={failedToLoadImage}
getPos={getPos}
loadImageFromFileSystem={setImageFromFileSystem}
maxFileSize={editor.storage.imageComponent.maxFileSize}
node={node}
setIsUploaded={setIsUploaded}
selected={selected}

View file

@ -10,33 +10,34 @@ import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/
import { getImageComponentImageFileMap, ImageAttributes } from "@/extensions/custom-image";
export const CustomImageUploader = (props: {
failedToLoadImage: boolean;
editor: Editor;
selected: boolean;
failedToLoadImage: boolean;
getPos: () => number;
loadImageFromFileSystem: (file: string) => void;
setIsUploaded: (isUploaded: boolean) => void;
maxFileSize: number;
node: ProsemirrorNode & {
attrs: ImageAttributes;
};
selected: boolean;
setIsUploaded: (isUploaded: boolean) => void;
updateAttributes: (attrs: Record<string, any>) => void;
getPos: () => number;
}) => {
const {
selected,
failedToLoadImage,
editor,
failedToLoadImage,
getPos,
loadImageFromFileSystem,
maxFileSize,
node,
selected,
setIsUploaded,
updateAttributes,
getPos,
} = props;
// ref
// refs
const fileInputRef = useRef<HTMLInputElement>(null);
const hasTriggeredFilePickerRef = useRef(false);
// derived values
const imageEntityId = node.attrs.id;
const imageComponentImageFileMap = useMemo(() => getImageComponentImageFileMap(editor), [editor]);
const onUpload = useCallback(
@ -71,11 +72,17 @@ export const CustomImageUploader = (props: {
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
);
// hooks
const { uploading: isImageBeingUploaded, uploadFile } = useUploader({ onUpload, editor, loadImageFromFileSystem });
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
uploader: uploadFile,
const { uploading: isImageBeingUploaded, uploadFile } = useUploader({
editor,
loadImageFromFileSystem,
maxFileSize,
onUpload,
});
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
editor,
maxFileSize,
pos: getPos(),
uploader: uploadFile,
});
// the meta data of the image component
@ -102,11 +109,17 @@ export const CustomImageUploader = (props: {
const onFileChange = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
const fileList = e.target.files;
if (!fileList) {
const filesList = e.target.files;
if (!filesList) {
return;
}
await uploadFirstImageAndInsertRemaining(editor, fileList, getPos(), uploadFile);
await uploadFirstImageAndInsertRemaining({
editor,
filesList,
maxFileSize,
pos: getPos(),
uploader: uploadFile,
});
},
[uploadFile, editor, getPos]
);

View file

@ -22,6 +22,7 @@ declare module "@tiptap/core" {
imageComponent: {
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
uploadImage: (file: File) => () => Promise<string> | undefined;
getImageSource?: (path: string) => () => string;
};
}
}
@ -36,7 +37,13 @@ export interface UploadImageExtensionStorage {
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
export const CustomImageExtension = (props: TFileHandler) => {
const { upload, delete: deleteImage, restore: restoreImage } = props;
const {
getAssetSrc,
upload,
delete: deleteImage,
restore: restoreImage,
validation: { maxFileSize },
} = props;
return Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
name: "imageComponent",
@ -87,8 +94,7 @@ export const CustomImageExtension = (props: TFileHandler) => {
});
imageSources.forEach(async (src) => {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await restoreImage(assetUrlWithWorkspaceId);
await restoreImage(src);
} catch (error) {
console.error("Error restoring image: ", error);
}
@ -114,6 +120,7 @@ export const CustomImageExtension = (props: TFileHandler) => {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
uploadInProgress: false,
maxFileSize,
};
},
@ -123,7 +130,13 @@ export const CustomImageExtension = (props: TFileHandler) => {
(props: { file?: File; pos?: number; event: "insert" | "drop" }) =>
({ commands }) => {
// Early return if there's an invalid file being dropped
if (props?.file && !isFileValid(props.file)) {
if (
props?.file &&
!isFileValid({
file: props.file,
maxFileSize,
})
) {
return false;
}
@ -166,6 +179,7 @@ export const CustomImageExtension = (props: TFileHandler) => {
const fileUrl = await upload(file);
return fileUrl;
},
getImageSource: (path: string) => () => getAssetSrc(path),
};
},

View file

@ -3,9 +3,13 @@ import { Image } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// components
import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custom-image";
// types
import { TFileHandler } from "@/types";
export const CustomReadOnlyImageExtension = () =>
Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
export const CustomReadOnlyImageExtension = (props: Pick<TFileHandler, "getAssetSrc">) => {
const { getAssetSrc } = props;
return Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
name: "imageComponent",
selectable: false,
group: "block",
@ -51,7 +55,14 @@ export const CustomReadOnlyImageExtension = () =>
};
},
addCommands() {
return {
getImageSource: (path: string) => () => getAssetSrc(path),
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
};

View file

@ -31,16 +31,11 @@ import {
// helpers
import { isValidHttpUrl } from "@/helpers/common";
// types
import { DeleteImage, IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage } from "@/types";
import { IMentionHighlight, IMentionSuggestion, TFileHandler } from "@/types";
type TArguments = {
enableHistory: boolean;
fileConfig: {
deleteFile: DeleteImage;
restoreFile: RestoreImage;
cancelUploadImage?: () => void;
uploadFile: UploadImage;
};
fileHandler: TFileHandler;
mentionConfig: {
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
mentionHighlights?: () => Promise<IMentionHighlight[]>;
@ -49,125 +44,118 @@ type TArguments = {
tabIndex?: number;
};
export const CoreEditorExtensions = ({
enableHistory,
fileConfig: { deleteFile, restoreFile, cancelUploadImage, uploadFile },
mentionConfig,
placeholder,
tabIndex,
}: TArguments) => [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc pl-7 space-y-2",
export const CoreEditorExtensions = (args: TArguments) => {
const { enableHistory, fileHandler, mentionConfig, placeholder, tabIndex } = args;
return [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc pl-7 space-y-2",
},
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal pl-7 space-y-2",
orderedList: {
HTMLAttributes: {
class: "list-decimal pl-7 space-y-2",
},
},
},
listItem: {
HTMLAttributes: {
class: "not-prose space-y-2",
listItem: {
HTMLAttributes: {
class: "not-prose space-y-2",
},
},
},
code: false,
codeBlock: false,
horizontalRule: false,
blockquote: false,
dropcursor: {
class: "text-custom-text-300",
},
...(enableHistory ? {} : { history: false }),
}),
CustomQuoteExtension,
DropHandlerExtension(),
CustomHorizontalRule.configure({
HTMLAttributes: {
class: "my-4 border-custom-border-400",
},
}),
CustomKeymap,
ListKeymap({ tabIndex }),
CustomLinkExtension.configure({
openOnClick: true,
autolink: true,
linkOnPaste: true,
protocols: ["http", "https"],
validate: (url: string) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
CustomTypographyExtension,
ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomImageExtension({
delete: deleteFile,
restore: restoreFile,
upload: uploadFile,
cancel: cancelUploadImage ?? (() => {}),
}),
TiptapUnderline,
TextStyle,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2 space-y-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "relative",
},
nested: true,
}),
CustomCodeBlockExtension.configure({
HTMLAttributes: {
class: "",
},
}),
CustomCodeMarkPlugin,
CustomCodeInlineExtension,
Markdown.configure({
html: true,
transformPastedText: true,
breaks: true,
}),
Table,
TableHeader,
TableCell,
TableRow,
CustomMention({
mentionSuggestions: mentionConfig.mentionSuggestions,
mentionHighlights: mentionConfig.mentionHighlights,
readonly: false,
}),
Placeholder.configure({
placeholder: ({ editor, node }) => {
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
code: false,
codeBlock: false,
horizontalRule: false,
blockquote: false,
dropcursor: {
class: "text-custom-text-300",
},
...(enableHistory ? {} : { history: false }),
}),
CustomQuoteExtension,
DropHandlerExtension(),
CustomHorizontalRule.configure({
HTMLAttributes: {
class: "my-4 border-custom-border-400",
},
}),
CustomKeymap,
ListKeymap({ tabIndex }),
CustomLinkExtension.configure({
openOnClick: true,
autolink: true,
linkOnPaste: true,
protocols: ["http", "https"],
validate: (url: string) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
CustomTypographyExtension,
ImageExtension(fileHandler).configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomImageExtension(fileHandler),
TiptapUnderline,
TextStyle,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2 space-y-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "relative",
},
nested: true,
}),
CustomCodeBlockExtension.configure({
HTMLAttributes: {
class: "",
},
}),
CustomCodeMarkPlugin,
CustomCodeInlineExtension,
Markdown.configure({
html: true,
transformPastedText: true,
breaks: true,
}),
Table,
TableHeader,
TableCell,
TableRow,
CustomMention({
mentionSuggestions: mentionConfig.mentionSuggestions,
mentionHighlights: mentionConfig.mentionHighlights,
readonly: false,
}),
Placeholder.configure({
placeholder: ({ editor, node }) => {
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
if (editor.storage.imageComponent.uploadInProgress) return "";
if (editor.storage.imageComponent.uploadInProgress) return "";
const shouldHidePlaceholder =
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
const shouldHidePlaceholder =
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
if (shouldHidePlaceholder) return "";
if (shouldHidePlaceholder) return "";
if (placeholder) {
if (typeof placeholder === "string") return placeholder;
else return placeholder(editor.isFocused, editor.getHTML());
}
if (placeholder) {
if (typeof placeholder === "string") return placeholder;
else return placeholder(editor.isFocused, editor.getHTML());
}
return "Press '/' for commands...";
},
includeChildren: true,
}),
CharacterCount,
CustomTextColorExtension,
CustomBackgroundColorExtension,
];
return "Press '/' for commands...";
},
includeChildren: true,
}),
CharacterCount,
CustomTextColorExtension,
CustomBackgroundColorExtension,
];
};

View file

@ -5,12 +5,19 @@ import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-par
// plugins
import { ImageExtensionStorage, TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image";
// types
import { DeleteImage, RestoreImage } from "@/types";
import { TFileHandler } from "@/types";
// extensions
import { CustomImageNode } from "@/extensions";
export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) =>
ImageExt.extend<any, ImageExtensionStorage>({
export const ImageExtension = (fileHandler: TFileHandler) => {
const {
delete: deleteImage,
getAssetSrc,
restore: restoreImage,
validation: { maxFileSize },
} = fileHandler;
return ImageExt.extend<any, ImageExtensionStorage>({
addKeyboardShortcuts() {
return {
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
@ -33,8 +40,7 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm
});
imageSources.forEach(async (src) => {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await restoreImage(assetUrlWithWorkspaceId);
await restoreImage(src);
} catch (error) {
console.error("Error restoring image: ", error);
}
@ -46,6 +52,7 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm
return {
deletedImageSet: new Map<string, boolean>(),
uploadInProgress: false,
maxFileSize,
};
},
@ -61,8 +68,15 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreIm
};
},
addCommands() {
return {
getImageSource: (path: string) => () => getAssetSrc(path),
};
},
// render custom image node
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
};

View file

@ -2,20 +2,33 @@ import Image from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomImageNode } from "@/extensions";
// types
import { TFileHandler } from "@/types";
export const ReadOnlyImageExtension = Image.extend({
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
height: {
default: null,
},
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
export const ReadOnlyImageExtension = (props: Pick<TFileHandler, "getAssetSrc">) => {
const { getAssetSrc } = props;
return Image.extend({
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
height: {
default: null,
},
};
},
addCommands() {
return {
getImageSource: (path: string) => () => getAssetSrc(path),
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomImageNode);
},
});
};

View file

@ -27,91 +27,104 @@ import {
// helpers
import { isValidHttpUrl } from "@/helpers/common";
// types
import { IMentionHighlight } from "@/types";
import { IMentionHighlight, TFileHandler } from "@/types";
export const CoreReadOnlyEditorExtensions = (mentionConfig: {
mentionHighlights?: () => Promise<IMentionHighlight[]>;
}) => [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc pl-7 space-y-2",
type Props = {
fileHandler: Pick<TFileHandler, "getAssetSrc">;
mentionConfig: {
mentionHighlights?: () => Promise<IMentionHighlight[]>;
};
};
export const CoreReadOnlyEditorExtensions = (props: Props) => {
const { fileHandler, mentionConfig } = props;
return [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc pl-7 space-y-2",
},
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal pl-7 space-y-2",
orderedList: {
HTMLAttributes: {
class: "list-decimal pl-7 space-y-2",
},
},
},
listItem: {
HTMLAttributes: {
class: "not-prose space-y-2",
listItem: {
HTMLAttributes: {
class: "not-prose space-y-2",
},
},
},
code: false,
codeBlock: false,
horizontalRule: false,
blockquote: false,
dropcursor: false,
gapcursor: false,
}),
CustomQuoteExtension,
CustomHorizontalRule.configure({
HTMLAttributes: {
class: "my-4 border-custom-border-400",
},
}),
CustomLinkExtension.configure({
openOnClick: true,
autolink: true,
linkOnPaste: true,
protocols: ["http", "https"],
validate: (url: string) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
CustomTypographyExtension,
ReadOnlyImageExtension.configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomReadOnlyImageExtension(),
TiptapUnderline,
TextStyle,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2 space-y-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "relative pointer-events-none",
},
nested: true,
}),
CustomCodeBlockExtension.configure({
HTMLAttributes: {
class: "",
},
}),
CustomCodeInlineExtension,
Markdown.configure({
html: true,
transformCopiedText: true,
}),
Table,
TableHeader,
TableCell,
TableRow,
CustomMention({
mentionHighlights: mentionConfig.mentionHighlights,
readonly: true,
}),
CharacterCount,
CustomTextColorExtension,
CustomBackgroundColorExtension,
HeadingListExtension,
];
code: false,
codeBlock: false,
horizontalRule: false,
blockquote: false,
dropcursor: false,
gapcursor: false,
}),
CustomQuoteExtension,
CustomHorizontalRule.configure({
HTMLAttributes: {
class: "my-4 border-custom-border-400",
},
}),
CustomLinkExtension.configure({
openOnClick: true,
autolink: true,
linkOnPaste: true,
protocols: ["http", "https"],
validate: (url: string) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
CustomTypographyExtension,
ReadOnlyImageExtension({
getAssetSrc: fileHandler.getAssetSrc,
}).configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomReadOnlyImageExtension({
getAssetSrc: fileHandler.getAssetSrc,
}),
TiptapUnderline,
TextStyle,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2 space-y-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "relative pointer-events-none",
},
nested: true,
}),
CustomCodeBlockExtension.configure({
HTMLAttributes: {
class: "",
},
}),
CustomCodeInlineExtension,
Markdown.configure({
html: true,
transformCopiedText: true,
}),
Table,
TableHeader,
TableCell,
TableRow,
CustomMention({
mentionHighlights: mentionConfig.mentionHighlights,
readonly: true,
}),
CharacterCount,
CustomTextColorExtension,
CustomBackgroundColorExtension,
HeadingListExtension,
];
};

View file

@ -75,12 +75,7 @@ export const useEditor = (props: CustomEditorProps) => {
extensions: [
...CoreEditorExtensions({
enableHistory,
fileConfig: {
uploadFile: fileHandler.upload,
deleteFile: fileHandler.delete,
restoreFile: fileHandler.restore,
cancelUploadImage: fileHandler.cancel,
},
fileHandler,
mentionConfig: {
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
mentionHighlights: mentionHandler.highlights,

View file

@ -1,17 +1,20 @@
import { DragEvent, useCallback, useEffect, useState } from "react";
import { Editor } from "@tiptap/core";
import { isFileValid } from "@/plugins/image";
// extensions
import { insertImagesSafely } from "@/extensions/drop";
// plugins
import { isFileValid } from "@/plugins/image";
export const useUploader = ({
onUpload,
editor,
loadImageFromFileSystem,
}: {
onUpload: (url: string) => void;
type TUploaderArgs = {
editor: Editor;
loadImageFromFileSystem: (file: string) => void;
}) => {
maxFileSize: number;
onUpload: (url: string) => void;
};
export const useUploader = (args: TUploaderArgs) => {
const { editor, loadImageFromFileSystem, maxFileSize, onUpload } = args;
// states
const [uploading, setUploading] = useState(false);
const uploadFile = useCallback(
@ -23,7 +26,10 @@ export const useUploader = ({
setUploading(true);
const fileNameTrimmed = trimFileName(file.name);
const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type });
const isValid = isFileValid(fileWithTrimmedName);
const isValid = isFileValid({
file: fileWithTrimmedName,
maxFileSize,
});
if (!isValid) {
setImageUploadInProgress(false);
return;
@ -64,15 +70,16 @@ export const useUploader = ({
return { uploading, uploadFile };
};
export const useDropZone = ({
uploader,
editor,
pos,
}: {
uploader: (file: File) => Promise<void>;
type TDropzoneArgs = {
editor: Editor;
maxFileSize: number;
pos: number;
}) => {
uploader: (file: File) => Promise<void>;
};
export const useDropZone = (args: TDropzoneArgs) => {
const { editor, maxFileSize, pos, uploader } = args;
// states
const [isDragging, setIsDragging] = useState<boolean>(false);
const [draggedInside, setDraggedInside] = useState<boolean>(false);
@ -101,8 +108,14 @@ export const useDropZone = ({
if (e.dataTransfer.files.length === 0) {
return;
}
const fileList = e.dataTransfer.files;
await uploadFirstImageAndInsertRemaining(editor, fileList, pos, uploader);
const filesList = e.dataTransfer.files;
await uploadFirstImageAndInsertRemaining({
editor,
filesList,
maxFileSize,
pos,
uploader,
});
},
[uploader, editor, pos]
);
@ -129,22 +142,33 @@ function trimFileName(fileName: string, maxLength = 100) {
return fileName;
}
type TMultipleImagesArgs = {
editor: Editor;
filesList: FileList;
maxFileSize: number;
pos: number;
uploader: (file: File) => Promise<void>;
};
// Upload the first image and insert the remaining images for uploading multiple image
// post insertion of image-component
export async function uploadFirstImageAndInsertRemaining(
editor: Editor,
fileList: FileList,
pos: number,
uploaderFn: (file: File) => Promise<void>
) {
export async function uploadFirstImageAndInsertRemaining(args: TMultipleImagesArgs) {
const { editor, filesList, maxFileSize, pos, uploader } = args;
const filteredFiles: File[] = [];
for (let i = 0; i < fileList.length; i += 1) {
const item = fileList.item(i);
if (item && item.type.indexOf("image") !== -1 && isFileValid(item)) {
for (let i = 0; i < filesList.length; i += 1) {
const item = filesList.item(i);
if (
item &&
item.type.indexOf("image") !== -1 &&
isFileValid({
file: item,
maxFileSize,
})
) {
filteredFiles.push(item);
}
}
if (filteredFiles.length !== fileList.length) {
if (filteredFiles.length !== filesList.length) {
console.warn("Some files were not images and have been ignored.");
}
if (filteredFiles.length === 0) {
@ -154,7 +178,7 @@ export async function uploadFirstImageAndInsertRemaining(
// Upload the first image
const firstFile = filteredFiles[0];
uploaderFn(firstFile);
uploader(firstFile);
// Insert the remaining images
const remainingFiles = filteredFiles.slice(1);

View file

@ -14,6 +14,7 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit
editorClassName,
editorProps = {},
extensions,
fileHandler,
forwardedRef,
handleEditorReady,
id,
@ -74,6 +75,7 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit
document: provider.document,
}),
],
fileHandler,
forwardedRef,
handleEditorReady,
mentionHandler,

View file

@ -11,7 +11,7 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
// props
import { CoreReadOnlyEditorProps } from "@/props";
// types
import { EditorReadOnlyRefApi, IMentionHighlight } from "@/types";
import { EditorReadOnlyRefApi, IMentionHighlight, TFileHandler } from "@/types";
interface CustomReadOnlyEditorProps {
initialValue?: string;
@ -19,6 +19,7 @@ interface CustomReadOnlyEditorProps {
forwardedRef?: MutableRefObject<EditorReadOnlyRefApi | null>;
extensions?: any;
editorProps?: EditorProps;
fileHandler: Pick<TFileHandler, "getAssetSrc">;
handleEditorReady?: (value: boolean) => void;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
@ -33,6 +34,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
forwardedRef,
extensions = [],
editorProps = {},
fileHandler,
handleEditorReady,
mentionHandler,
provider,
@ -52,7 +54,10 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
},
extensions: [
...CoreReadOnlyEditorExtensions({
mentionHighlights: mentionHandler.highlights,
mentionConfig: {
mentionHighlights: mentionHandler.highlights,
},
fileHandler,
}),
...extensions,
],

View file

@ -47,10 +47,9 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag
});
async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise<void> {
if (!src) return;
try {
if (!src) return;
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await deleteImage(assetUrlWithWorkspaceId);
await deleteImage(src);
} catch (error) {
console.error("Error deleting image: ", error);
}

View file

@ -48,10 +48,9 @@ export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: Restor
});
async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise<void> {
if (!src) return;
try {
if (!src) return;
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await restoreImage(assetUrlWithWorkspaceId);
await restoreImage(src);
} catch (error) {
console.error("Error restoring image: ", error);
throw error;

View file

@ -1,25 +1,26 @@
export function isFileValid(file: File, showAlert = true): boolean {
type TArgs = {
file: File;
maxFileSize: number;
};
export const isFileValid = (args: TArgs): boolean => {
const { file, maxFileSize } = args;
if (!file) {
if (showAlert) {
alert("No file selected. Please select a file to upload.");
}
alert("No file selected. Please select a file to upload.");
return false;
}
const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
if (!allowedTypes.includes(file.type)) {
if (showAlert) {
alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file.");
}
alert("Invalid file type. Please select a JPEG, JPG, PNG, or WEBP image file.");
return false;
}
if (file.size > 5 * 1024 * 1024) {
if (showAlert) {
alert("File size too large. Please select a file smaller than 5MB.");
}
if (file.size > maxFileSize) {
alert(`File size too large. Please select a file smaller than ${maxFileSize / 1024 / 1024}MB.`);
return false;
}
return true;
}
};

View file

@ -44,5 +44,6 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
};
export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & {
fileHandler: Pick<TFileHandler, "getAssetSrc">;
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
};

View file

@ -1,10 +1,18 @@
import { DeleteImage, RestoreImage, UploadImage } from "@/types";
export type TFileHandler = {
getAssetSrc: (path: string) => string;
cancel: () => void;
delete: DeleteImage;
upload: UploadImage;
restore: RestoreImage;
validation: {
/**
* @description max file size in bytes
* @example enter 5242880( 5* 1024 * 1024) for 5MB
*/
maxFileSize: number;
};
};
export type TEditorFontStyle = "sans-serif" | "serif" | "monospace";

View file

@ -108,6 +108,7 @@ export interface IReadOnlyEditorProps {
containerClassName?: string;
displayConfig?: TDisplayConfig;
editorClassName?: string;
fileHandler: Pick<TFileHandler, "getAssetSrc">;
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
id: string;
initialValue: string;

View file

@ -1,5 +1,5 @@
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<void>;
export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
export type RestoreImage = (assetUrlWithWorkspaceId: string) => Promise<void>;
export type UploadImage = (file: File) => Promise<string>;

View file

@ -20,7 +20,7 @@ export interface IAnalyticsData {
}
export interface IAnalyticsAssigneeDetails {
assignees__avatar: string | null;
assignees__avatar_url: string | null;
assignees__display_name: string | null;
assignees__first_name: string;
assignees__id: string | null;
@ -87,7 +87,7 @@ export interface IExportAnalyticsFormData {
}
export interface IDefaultAnalyticsUser {
assignees__avatar: string | null;
assignees__avatar_url: string | null;
assignees__first_name: string;
assignees__last_name: string;
assignees__display_name: string;
@ -99,7 +99,7 @@ export interface IDefaultAnalyticsResponse {
issue_completed_month_wise: { month: number; count: number }[];
most_issue_closed_user: IDefaultAnalyticsUser[];
most_issue_created_user: {
created_by__avatar: string | null;
created_by__avatar_url: string | null;
created_by__first_name: string;
created_by__last_name: string;
created_by__display_name: string;

View file

@ -1,17 +0,0 @@
export type TCurrentUserAccount = {
id: string | undefined;
user: string | undefined;
provider_account_id: string | undefined;
provider: "google" | "github" | "gitlab" | string | undefined;
access_token: string | undefined;
access_token_expired_at: Date | undefined;
refresh_token: string | undefined;
refresh_token_expired_at: Date | undefined;
last_connected_at: Date | undefined;
metadata: object | undefined;
created_at: Date | undefined;
updated_at: Date | undefined;
};

View file

@ -1,3 +1 @@
export * from "./user";
export * from "./profile";
export * from "./accounts";

View file

@ -1,30 +0,0 @@
export type TCurrentUser = {
id: string | undefined;
avatar: string | undefined;
cover_image: string | undefined;
date_joined: Date | undefined;
display_name: string | undefined;
email: string | undefined;
first_name: string | undefined;
last_name: string | undefined;
is_active: boolean;
is_bot: boolean;
is_email_verified: boolean;
is_managed: boolean;
mobile_number: string | undefined;
user_timezone: string | undefined;
username: string | undefined;
is_password_autoset: boolean;
};
export type TCurrentUserSettings = {
id: string | undefined;
email: string | undefined;
workspace: {
last_workspace_id: string | undefined;
last_workspace_slug: string | undefined;
fallback_workspace_id: string | undefined;
fallback_workspace_slug: string | undefined;
invites: number | undefined;
};
};

View file

@ -20,7 +20,7 @@ export type TCycleEstimateDistributionBase = {
export type TCycleAssigneesDistribution = {
assignee_id: string | null;
avatar: string | null;
avatar_url: string | null;
first_name: string | null;
last_name: string | null;
display_name: string | null;

View file

@ -48,3 +48,14 @@ export enum ENotificationFilterType {
ASSIGNED = "assigned",
SUBSCRIBED = "subscribed",
}
export enum EFileAssetType {
COMMENT_DESCRIPTION = "COMMENT_DESCRIPTION",
ISSUE_ATTACHMENT = "ISSUE_ATTACHMENT",
ISSUE_DESCRIPTION = "ISSUE_DESCRIPTION",
PAGE_DESCRIPTION = "PAGE_DESCRIPTION",
PROJECT_COVER = "PROJECT_COVER",
USER_AVATAR = "USER_AVATAR",
USER_COVER = "USER_COVER",
WORKSPACE_LOGO = "WORKSPACE_LOGO",
}

32
packages/types/src/file.d.ts vendored Normal file
View file

@ -0,0 +1,32 @@
import { EFileAssetType } from "./enums"
export type TFileMetaDataLite = {
name: string;
// file size in bytes
size: number;
type: string;
}
export type TFileEntityInfo = {
entity_identifier: string;
entity_type: EFileAssetType;
}
export type TFileMetaData = TFileMetaDataLite & TFileEntityInfo;
export type TFileSignedURLResponse = {
asset_id: string;
asset_url: string;
upload_data: {
url: string;
fields: {
"Content-Type": string;
key: string;
"x-amz-algorithm": string;
"x-amz-credential": string;
"x-amz-date": string;
policy: string;
"x-amz-signature": string;
};
};
};

View file

@ -29,4 +29,5 @@ export * from "./pragmatic";
export * from "./publish";
export * from "./workspace-notifications";
export * from "./favorite";
export * from "./file";
export * from "./workspace-draft-issues/base";

View file

@ -1,7 +1,6 @@
// All the app integrations that are available
export interface IAppIntegration {
author: string;
author: "";
avatar_url: string | null;
created_at: string;
created_by: string | null;

View file

@ -40,7 +40,7 @@ export type TIssueActivityUserDetail = {
id: string;
first_name: string;
last_name: string;
avatar: string;
avatar_url: string;
is_bot: boolean;
display_name: string;
};

View file

@ -45,7 +45,7 @@ export type TIssue = TBaseIssue & {
is_subscribed?: boolean;
parent?: Partial<TBaseIssue>;
issue_reactions?: TIssueReaction[];
issue_attachment?: TIssueAttachment[];
issue_attachments?: TIssueAttachment[];
issue_link?: TIssueLink[];
// tempId is used for optimistic updates. It is not a part of the API response.
tempId?: string;

View file

@ -1,17 +1,22 @@
import { TFileSignedURLResponse } from "../file";
export type TIssueAttachment = {
id: string;
attributes: {
name: string;
size: number;
};
asset: string;
asset_url: string;
issue_id: string;
//need
// required
updated_at: string;
updated_by: string;
};
export type TIssueAttachmentUploadResponse = TFileSignedURLResponse & {
attachment: TIssueAttachment
};
export type TIssueAttachmentMap = {
[issue_id: string]: TIssueAttachment;
};

View file

@ -26,7 +26,7 @@ export type TModuleEstimateDistributionBase = {
export type TModuleAssigneesDistribution = {
assignee_id: string | null;
avatar: string | null;
avatar_url: string | null;
first_name: string | null;
last_name: string | null;
display_name: string | null;

View file

@ -18,7 +18,7 @@ export interface IProject {
close_in: number;
created_at: Date;
created_by: string;
cover_image: string | null;
cover_image_url: string;
cycle_view: boolean;
issue_views_view: boolean;
module_view: boolean;
@ -75,7 +75,7 @@ export interface IProjectMap {
export interface IProjectMemberLite {
id: string;
member__avatar: string;
member__avatar_url: string;
member__display_name: string;
member_id: string;
}

View file

@ -3,17 +3,21 @@ import { TUserPermissions } from "./enums";
type TLoginMediums = "email" | "magic-code" | "github" | "gitlab" | "google";
export interface IUser {
id: string;
avatar: string | null;
cover_image: string | null;
date_joined: string;
export interface IUserLite {
avatar_url: string;
display_name: string;
email: string;
email?: string;
first_name: string;
last_name: string;
is_active: boolean;
id: string;
is_bot: boolean;
last_name: string;
}
export interface IUser extends IUserLite {
cover_image_url: string | null;
date_joined: string;
email: string;
is_active: boolean;
is_email_verified: boolean;
is_password_autoset: boolean;
is_tour_completed: boolean;
@ -86,15 +90,6 @@ export interface IUserTheme {
sidebarBackground: string | undefined;
}
export interface IUserLite {
avatar: string;
display_name: string;
email?: string;
first_name: string;
id: string;
is_bot: boolean;
last_name: string;
}
export interface IUserMemberLite extends IUserLite {
email?: string;
@ -158,13 +153,8 @@ export interface IUserProfileProjectSegregation {
id: string;
pending_issues: number;
}[];
user_data: {
avatar: string;
cover_image: string | null;
user_data: Pick<IUser, "avatar_url" | "cover_image_url" | "display_name" | "first_name" | "last_name"> & {
date_joined: Date;
display_name: string;
first_name: string;
last_name: string;
user_timezone: string;
};
}

View file

@ -14,8 +14,7 @@ export interface IWorkspace {
readonly updated_at: Date;
name: string;
url: string;
logo: string | null;
slug: string;
logo_url: string | null;
readonly total_members: number;
readonly slug: string;
readonly created_by: string;
@ -71,7 +70,7 @@ export interface IWorkspaceMember {
member: IUserLite;
role: TUserPermissions;
created_at?: string;
avatar?: string;
avatar_url?: string;
email?: string;
first_name?: string;
last_name?: string;