diff --git a/apps/server/src/modules/attachments/attachments.controller.ts b/apps/server/src/modules/attachments/attachments.controller.ts index 27659895..5dab8429 100644 --- a/apps/server/src/modules/attachments/attachments.controller.ts +++ b/apps/server/src/modules/attachments/attachments.controller.ts @@ -11,12 +11,14 @@ import { UseInterceptors, } from '@nestjs/common'; import { FilesInterceptor } from '@nestjs/platform-express'; +import { SignedURLBody } from '@tegonhq/types'; import { Response } from 'express'; import { SessionContainer } from 'supertokens-node/recipe/session'; import { AuthGuard } from 'modules/auth/auth.guard'; import { Session as SessionDecorator, + UserId, Workspace, } from 'modules/auth/session.decorator'; @@ -56,14 +58,47 @@ export class AttachmentController { ); } + @Post('get-signed-url') + @UseGuards(AuthGuard) + async getUploadSignedUrl( + @Body() attachmentBody: SignedURLBody, + @Workspace() workspaceId: string, + @UserId() userId: string, + ) { + return await this.attachementService.uploadGenerateSignedURL( + attachmentBody, + userId, + workspaceId, + ); + } + + @Get('get-signed-url/:attachmentId') + @UseGuards(AuthGuard) + async getFileFromGCSSignedURL( + @Workspace() workspaceId: string, + @Param() attachementRequestParams: AttachmentRequestParams, + ) { + try { + return await this.attachementService.getFileFromGCSSignedUrl( + attachementRequestParams, + workspaceId, + ); + } catch (error) { + return undefined; + } + } + @Get(':workspaceId/:attachmentId') + @UseGuards(AuthGuard) async getFileFromGCS( + @Workspace() workspaceId: string, @Param() attachementRequestParams: AttachmentRequestParams, @Res() res: Response, ) { try { const file = await this.attachementService.getFileFromGCS( attachementRequestParams, + workspaceId, ); res.setHeader('Content-Type', file.contentType); res.send(file.buffer); @@ -75,9 +110,13 @@ export class AttachmentController { @Delete(':workspaceId/:attachmentId') @UseGuards(AuthGuard) async deleteAttachment( + @Workspace() workspaceId: string, @Param() attachementRequestParams: AttachmentRequestParams, ) { - await this.attachementService.deleteAttachment(attachementRequestParams); + await this.attachementService.deleteAttachment( + attachementRequestParams, + workspaceId, + ); return { message: 'Attachment deleted successfully' }; } } diff --git a/apps/server/src/modules/attachments/attachments.interface.ts b/apps/server/src/modules/attachments/attachments.interface.ts index 4d2964e9..c4f49e4f 100644 --- a/apps/server/src/modules/attachments/attachments.interface.ts +++ b/apps/server/src/modules/attachments/attachments.interface.ts @@ -1,9 +1,6 @@ import { IsOptional, IsString } from 'class-validator'; export class AttachmentRequestParams { - @IsString() - workspaceId: string; - @IsString() attachmentId: string; } diff --git a/apps/server/src/modules/attachments/attachments.service.ts b/apps/server/src/modules/attachments/attachments.service.ts index e407f02f..0bfef32c 100644 --- a/apps/server/src/modules/attachments/attachments.service.ts +++ b/apps/server/src/modules/attachments/attachments.service.ts @@ -4,14 +4,23 @@ import { Injectable, InternalServerErrorException, } from '@nestjs/common'; -import { AttachmentResponse, AttachmentStatusEnum } from '@tegonhq/types'; +import { + AttachmentResponse, + AttachmentStatusEnum, + SignedURLBody, +} from '@tegonhq/types'; import { PrismaService } from 'nestjs-prisma'; +import { LoggerService } from 'modules/logger/logger.service'; + import { AttachmentRequestParams, ExternalFile } from './attachments.interface'; @Injectable() export class AttachmentService { private storage: Storage; private bucketName = process.env.GCP_BUCKET_NAME; + private readonly logger: LoggerService = new LoggerService( + 'AttachmentService', + ); constructor(private prisma: PrismaService) { this.storage = new Storage({ @@ -19,6 +28,57 @@ export class AttachmentService { }); } + async uploadGenerateSignedURL( + file: SignedURLBody, + userId: string, + workspaceId: string, + ) { + const bucket = this.storage.bucket(this.bucketName); + const attachment = await this.prisma.attachment.create({ + data: { + fileName: file.fileName, + originalName: file.originalName, + fileType: file.mimetype, + size: file.size, + status: AttachmentStatusEnum.Pending, + fileExt: file.originalName.split('.').pop(), + workspaceId, + ...(userId ? { uploadedById: userId } : {}), + }, + include: { + workspace: true, + }, + }); + + try { + const [url] = await bucket + .file(`${workspaceId}/${attachment.id}.${attachment.fileExt}`) + .getSignedUrl({ + version: 'v4', + action: 'write', + expires: Date.now() + 15 * 60 * 1000, // 15 minutes + contentType: file.contentType, + }); + + const publicURL = `${process.env.PUBLIC_ATTACHMENT_URL}/v1/attachment/${workspaceId}/${attachment.id}`; + + return { + url, + attachment: { + publicURL, + id: attachment.id, + fileType: attachment.fileType, + originalName: attachment.originalName, + size: attachment.size, + }, + }; + } catch (err) { + this.logger.error(err); + + return undefined; + } + } + async uploadAttachment( files: Express.Multer.File[], userId: string, @@ -109,8 +169,11 @@ export class AttachmentService { return await Promise.all(attachmentPromises); } - async getFileFromGCS(attachementRequestParams: AttachmentRequestParams) { - const { attachmentId, workspaceId } = attachementRequestParams; + async getFileFromGCS( + attachementRequestParams: AttachmentRequestParams, + workspaceId: string, + ) { + const { attachmentId } = attachementRequestParams; const attachment = await this.prisma.attachment.findFirst({ where: { id: attachmentId, workspaceId }, @@ -139,8 +202,56 @@ export class AttachmentService { }; } - async deleteAttachment(attachementRequestParams: AttachmentRequestParams) { - const { attachmentId, workspaceId } = attachementRequestParams; + async getFileFromGCSSignedUrl( + attachementRequestParams: AttachmentRequestParams, + workspaceId: string, + ) { + const { attachmentId } = attachementRequestParams; + + const attachment = await this.prisma.attachment.findFirst({ + where: { id: attachmentId, workspaceId }, + }); + + if (!attachment) { + throw new BadRequestException( + `No attachment found for this id: ${attachmentId}`, + ); + } + + const bucket = this.storage.bucket(this.bucketName); + const filePath = `${workspaceId}/${attachment.id}.${attachment.fileExt}`; + const file = bucket.file(filePath); + + const [exists] = await file.exists(); + if (!exists) { + throw new BadRequestException('File not found'); + } + + // Get file metadata for size + const [metadata] = await file.getMetadata(); + + const [signedUrl] = await file.getSignedUrl({ + version: 'v4', + action: 'read', + expires: Date.now() + 60 * 60 * 1000, // 1 hour + // Enable range requests and other necessary headers + responseDisposition: 'inline', + responseType: attachment.fileType, + }); + + return { + signedUrl, + contentType: attachment.fileType, + originalName: attachment.originalName, + size: metadata.size, + }; + } + + async deleteAttachment( + attachementRequestParams: AttachmentRequestParams, + workspaceId: string, + ) { + const { attachmentId } = attachementRequestParams; const attachment = await this.prisma.attachment.findFirst({ where: { id: attachmentId, workspaceId }, diff --git a/apps/server/src/modules/users/users.controller.ts b/apps/server/src/modules/users/users.controller.ts index 0097c576..3444a538 100644 --- a/apps/server/src/modules/users/users.controller.ts +++ b/apps/server/src/modules/users/users.controller.ts @@ -5,6 +5,7 @@ import { Get, Param, Post, + Put, Query, Req, Res, @@ -44,12 +45,16 @@ export class UsersController { async getUser( @SessionDecorator() session: SessionContainer, @Query() userIdParams: { userIds: string }, + @Workspace() workspaceId: string, ): Promise { try { if (userIdParams.userIds && userIdParams.userIds.split(',').length > 0) { - return await this.users.getUsersbyId({ - userIds: userIdParams.userIds.split(','), - }); + return await this.users.getUsersbyId( + { + userIds: userIdParams.userIds.split(','), + }, + workspaceId, + ); } } catch (e) {} @@ -61,8 +66,11 @@ export class UsersController { @Post() @UseGuards(AuthGuard) - async getUsersById(@Body() getUsersDto: GetUsersDto): Promise { - return await this.users.getUsersbyId(getUsersDto); + async getUsersById( + @Body() getUsersDto: GetUsersDto, + @Workspace() workspaceId: string, + ): Promise { + return await this.users.getUsersbyId(getUsersDto, workspaceId); } @Post('impersonate') @@ -139,6 +147,7 @@ export class UsersController { return this.users.authorizeCode(userId, codeBody); } + @Put() @UseGuards(AuthGuard) async updateUser( @UserId() userId: string, @@ -146,7 +155,6 @@ export class UsersController { updateUserBody: UpdateUserBody, ): Promise { const user = await this.users.updateUser(userId, updateUserBody); - return user; } } diff --git a/apps/server/src/modules/users/users.service.ts b/apps/server/src/modules/users/users.service.ts index ac4ede17..3dd18da8 100644 --- a/apps/server/src/modules/users/users.service.ts +++ b/apps/server/src/modules/users/users.service.ts @@ -96,14 +96,17 @@ export class UsersService { return { ...serializeUser, invites }; } - async getUsersbyId(getUsersDto: GetUsersDto): Promise { + async getUsersbyId( + getUsersDto: GetUsersDto, + workspaceId: string, + ): Promise { const where: Prisma.UserWhereInput = { id: { in: getUsersDto.userIds }, }; - if (getUsersDto.workspaceId) { + if (workspaceId) { where.usersOnWorkspaces = { - some: { workspaceId: getUsersDto.workspaceId }, + some: { workspaceId }, }; } diff --git a/apps/webapp/package.json b/apps/webapp/package.json index f936985a..a30a1d99 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -19,6 +19,7 @@ "@tegonhq/types": "workspace:*", "@tegonhq/ui": "workspace:*", "@tiptap/core": "^2.3.0", + "@tiptap/extension-mention": "^2.10.3", "@tiptap/react": "^2.5.4", "@typeform/embed-react": "^3.17.0", "ai": "^3.2.37", diff --git a/apps/webapp/src/common/theme-provider.tsx b/apps/webapp/src/common/theme-provider.tsx new file mode 100644 index 00000000..1f84abd1 --- /dev/null +++ b/apps/webapp/src/common/theme-provider.tsx @@ -0,0 +1,8 @@ +'use client'; + +import { ThemeProvider as NextThemesProvider } from 'next-themes'; +import { type ThemeProviderProps } from 'next-themes/dist/types'; + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children}; +} diff --git a/apps/webapp/src/modules/settings/personal-settings/profile/profile-form.tsx b/apps/webapp/src/modules/settings/personal-settings/profile/profile-form.tsx index 9cf0bac1..dcce15b7 100644 --- a/apps/webapp/src/modules/settings/personal-settings/profile/profile-form.tsx +++ b/apps/webapp/src/modules/settings/personal-settings/profile/profile-form.tsx @@ -41,10 +41,7 @@ export function ProfileForm() { }); const onSubmit = (values: UpdateUserParams) => { - updateUser({ - ...values, - userId: userData.id, - }); + updateUser(values); }; return ( diff --git a/apps/webapp/src/services/attachment/get-signed-url.ts b/apps/webapp/src/services/attachment/get-signed-url.ts new file mode 100644 index 00000000..48cf5340 --- /dev/null +++ b/apps/webapp/src/services/attachment/get-signed-url.ts @@ -0,0 +1,26 @@ +import { type UseQueryResult, useQuery } from 'react-query'; + +import type { User } from 'common/types'; + +import { type XHRErrorResponse, ajaxGet } from 'services/utils'; + +/** + * Query Key for Get user. + */ +export const GetSignedURL = 'getSignedURLQuery'; + +export function getSignedURL(attachmentId: string) { + return ajaxGet({ + url: `/api/v1/attachment/get-signed-url/${attachmentId}`, + }); +} + +export function useGetSignedURLQuery( + attachmentId: string, +): UseQueryResult { + return useQuery([GetSignedURL], () => getSignedURL(attachmentId), { + retry: 1, + staleTime: Infinity, + refetchOnWindowFocus: false, // Frequency of Change would be Low + }); +} diff --git a/apps/webapp/src/services/attachment/index.ts b/apps/webapp/src/services/attachment/index.ts new file mode 100644 index 00000000..451df0cf --- /dev/null +++ b/apps/webapp/src/services/attachment/index.ts @@ -0,0 +1 @@ +export * from './get-signed-url'; diff --git a/apps/webapp/src/services/users/update-user.tsx b/apps/webapp/src/services/users/update-user.tsx index cc8dddd9..1851f0d5 100644 --- a/apps/webapp/src/services/users/update-user.tsx +++ b/apps/webapp/src/services/users/update-user.tsx @@ -2,17 +2,16 @@ import { useMutation } from 'react-query'; import type { User } from 'common/types'; -import { ajaxPost } from 'services/utils'; +import { ajaxPut } from 'services/utils'; export interface UpdateUserParams { fullname: string; username: string; - userId: string; } -function updateUser({ userId, fullname, username }: UpdateUserParams) { - return ajaxPost({ - url: `/api/v1/users/${userId}`, +function updateUser({ fullname, username }: UpdateUserParams) { + return ajaxPut({ + url: `/api/v1/users`, data: { fullname, username, diff --git a/packages/types/src/attachment/get-signed-url.ts b/packages/types/src/attachment/get-signed-url.ts new file mode 100644 index 00000000..99a30065 --- /dev/null +++ b/packages/types/src/attachment/get-signed-url.ts @@ -0,0 +1,23 @@ +import { IsNumber, IsOptional, IsString } from 'class-validator'; + +export class SignedURLBody { + @IsOptional() + @IsString() + fileName: string; + + @IsOptional() + @IsString() + originalName: string; + + @IsOptional() + @IsString() + contentType: string; + + @IsOptional() + @IsNumber() + size: number; + + @IsOptional() + @IsString() + mimetype: string; +} diff --git a/packages/types/src/attachment/index.ts b/packages/types/src/attachment/index.ts index 40b1e85a..d6fcb8dc 100644 --- a/packages/types/src/attachment/index.ts +++ b/packages/types/src/attachment/index.ts @@ -1,2 +1,3 @@ export * from './attachment.entity'; export * from './attachment.interface'; +export * from './get-signed-url'; diff --git a/packages/types/src/user/get-users.dto.ts b/packages/types/src/user/get-users.dto.ts index 22dc538a..daea7788 100644 --- a/packages/types/src/user/get-users.dto.ts +++ b/packages/types/src/user/get-users.dto.ts @@ -1,4 +1,3 @@ export class GetUsersDto { userIds: string[]; - workspaceId?: string; } diff --git a/packages/ui/src/components/ui/editor/file-extension/file-component.tsx b/packages/ui/src/components/ui/editor/file-extension/file-component.tsx index a89f6972..863d1447 100644 --- a/packages/ui/src/components/ui/editor/file-extension/file-component.tsx +++ b/packages/ui/src/components/ui/editor/file-extension/file-component.tsx @@ -1,5 +1,6 @@ import { RiDownloadLine } from '@remixicon/react'; import { NodeViewWrapper } from '@tiptap/react'; +import axios from 'axios'; import { filesize } from 'filesize'; import React from 'react'; @@ -7,10 +8,46 @@ import { DocumentLine } from '@tegonhq/ui/icons'; import { Button } from '../../button'; import { Loader } from '../../loader'; +import { Progress } from '../../progress'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export const FileComponent = (props: any) => { const type = props.node.attrs.type; + const [loading, setLoading] = React.useState(false); + const [src, setSrc] = React.useState(undefined); + + React.useEffect(() => { + if (props.node.attrs.attachmentId) { + getData(); + } else { + setSrc(props.node.attrs.src); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const getData = async () => { + setLoading(true); + + try { + const { + data: { signedUrl }, + } = await axios.get( + `http://localhost:3000/api/v1/attachment/get-signed-url/${props.node.attrs.attachmentId}`, + { + withCredentials: true, + }, + ); + setSrc(signedUrl); + } catch (e) { + setSrc(props.node.attrs.src); + } + + setLoading(false); + }; + + if (loading || !src) { + return null; + } return ( @@ -21,7 +58,7 @@ export const FileComponent = (props: any) => {
@@ -32,7 +69,7 @@ export const FileComponent = (props: any) => { size="xs" className="px-1" onClick={() => { - window.open(props.node.attrs.src, '_blank'); + window.open(src, '_blank'); }} > @@ -42,38 +79,48 @@ export const FileComponent = (props: any) => { ) : ( <> - +
+
+ -
-
{props.node.attrs.alt}
- {props.node.attrs.size > 0 && ( -
- {filesize(props.node.attrs.size, { standard: 'jedec' })} +
+
{props.node.attrs.alt}
+ {props.node.attrs.size > 0 && ( +
+ {filesize(props.node.attrs.size, { standard: 'jedec' })} +
+ )} +
+ + +
+ + {props.node.attrs.uploading && ( +
+
)}
- - )} {props.node.attrs.uploading && ( -
- -
+ <> +
+ +
+ )}
diff --git a/packages/ui/src/components/ui/editor/file-extension/file-extension.tsx b/packages/ui/src/components/ui/editor/file-extension/file-extension.tsx index 95c5d581..ea22d218 100644 --- a/packages/ui/src/components/ui/editor/file-extension/file-extension.tsx +++ b/packages/ui/src/components/ui/editor/file-extension/file-extension.tsx @@ -13,6 +13,9 @@ export const fileExtension = Node.create({ src: { default: undefined, }, + attachmentId: { + default: undefined, + }, alt: { default: undefined, }, @@ -28,6 +31,9 @@ export const fileExtension = Node.create({ url: { default: 0, }, + progress: { + default: 0, + }, }; }, diff --git a/packages/ui/src/components/ui/editor/image-extension/image-component.tsx b/packages/ui/src/components/ui/editor/image-extension/image-component.tsx index 9f980929..a31ef605 100644 --- a/packages/ui/src/components/ui/editor/image-extension/image-component.tsx +++ b/packages/ui/src/components/ui/editor/image-extension/image-component.tsx @@ -1,6 +1,7 @@ /* eslint-disable @next/next/no-img-element */ import { RiDownloadLine } from '@remixicon/react'; import { NodeViewWrapper } from '@tiptap/react'; +import axios from 'axios'; import { ArrowRight, ZoomIn, ZoomOut } from 'lucide-react'; import React from 'react'; import Lightbox from 'yet-another-react-lightbox'; @@ -12,11 +13,15 @@ import { ArrowLeft, Close, FullscreenLine } from '@tegonhq/ui/icons'; import { getNodeTypesWithImageExtension, type AttrType } from './utils'; import { Button } from '../../button'; import { Loader } from '../../loader'; +import { Progress } from '../../progress'; import { useEditor } from '../editor'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export const ImageComponent = (props: any) => { const { editor } = useEditor(); + const [loading, setLoading] = React.useState(false); + const [src, setSrc] = React.useState(undefined); + const setOpen = (openViewer: boolean) => { props.updateAttributes({ openViewer, @@ -25,42 +30,68 @@ export const ImageComponent = (props: any) => { React.useEffect(() => { setOpen(false); + + if (props.node.attrs.attachmentId) { + getData(); + } else { + setSrc(props.node.attrs.src); + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - React.useEffect(() => { - const handleKeyDown = (event: any) => { - if (event.key === 'Escape') { - setOpen(false); - } - }; - - if (props.node.attrs.openViewer) { - window.addEventListener('keydown', handleKeyDown); + const getData = async () => { + setLoading(true); + + try { + const { + data: { signedUrl }, + } = await axios.get( + `http://localhost:3000/api/v1/attachment/get-signed-url/${props.node.attrs.attachmentId}`, + { + withCredentials: true, + }, + ); + setSrc(signedUrl); + } catch (e) { + setSrc(props.node.attrs.src); } - // Clean up the event listener when the component unmounts or full screen is closed - return () => { - window.removeEventListener('keydown', handleKeyDown); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.node.attrs.openViewer]); + setLoading(false); + }; + const images = getNodeTypesWithImageExtension(editor); + if (loading || !src) { + return null; + } + return (
- {props.node.attrs.alt} +
+ {props.node.attrs.alt} + {props.node.attrs.uploading && ( +
+ +
+ )} +
{props.node.attrs.uploading && ( -
- -
+ <> +
+ +
+ )} {!props.node.attrs.uploading && ( @@ -70,7 +101,7 @@ export const ImageComponent = (props: any) => { size="xs" className="px-1" onClick={() => { - window.open(props.node.attrs.src, '_blank'); + window.open(src, '_blank'); }} > diff --git a/packages/ui/src/components/ui/editor/image-extension/image-extension.tsx b/packages/ui/src/components/ui/editor/image-extension/image-extension.tsx index 00205b88..40d05f8f 100644 --- a/packages/ui/src/components/ui/editor/image-extension/image-extension.tsx +++ b/packages/ui/src/components/ui/editor/image-extension/image-extension.tsx @@ -13,6 +13,9 @@ export const imageExtension = Node.create({ src: { default: undefined, }, + attachmentId: { + default: undefined, + }, alt: { default: undefined, }, @@ -22,6 +25,9 @@ export const imageExtension = Node.create({ openViewer: { default: false, }, + progress: { + default: 0, + }, }; }, diff --git a/packages/ui/src/components/ui/editor/utils.ts b/packages/ui/src/components/ui/editor/utils.ts index 78734d3d..1592a271 100644 --- a/packages/ui/src/components/ui/editor/utils.ts +++ b/packages/ui/src/components/ui/editor/utils.ts @@ -3,23 +3,43 @@ import type { Editor } from '@tiptap/core'; import axios from 'axios'; import { type ImageUploadOptions } from 'novel/plugins'; -const onUploadFile = async (file: File) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const workspaceId = (window as any).workspaceId; - const formData = new FormData(); +interface ImageUploadOptionsExtend extends ImageUploadOptions { + onUpload: ( + file: File, + callback?: (progress: number) => void, + ) => Promise; +} + +const onUploadFile = async ( + file: File, + callback?: (progress: number) => void, +) => { + const { + data: { url, attachment }, + } = await axios.post('/api/v1/attachment/get-signed-url', { + fileName: file.name, + contentType: file.type, + mimetype: file.type, + size: file.size, + originalName: file.name, + }); - formData.append('files', file); - const response: any = await axios.post( - `/api/v1/attachment/upload?workspaceId=${workspaceId}`, - formData, - ); + await axios.put(url, file, { + headers: { 'Content-Type': file.type }, + onUploadProgress: (progressEvent) => { + const percentCompleted = Math.round( + (progressEvent.loaded * 100) / progressEvent.total, + ); + callback && callback(percentCompleted); + }, + }); // This should return a src of the uploaded image - return response.data[0]; + return attachment; }; export const createImageUpload = - ({ validateFn, onUpload }: ImageUploadOptions): UploadFileFn => + ({ validateFn, onUpload }: ImageUploadOptionsExtend): UploadFileFn => (file, editor, pos) => { // check if the file is an image const validated = validateFn?.(file) as unknown as boolean; @@ -57,11 +77,20 @@ export const createImageUpload = .focus() .run(); - onUpload(file).then((response: any) => { + onUpload(file, (progress) => { + updateNodeAttrs(editor, tempFileURL, { + src: tempFileURL, + uploading: true, + progress, + openViewer: false, + alt: file.name, + }); + }).then((response: any) => { updateNodeAttrs(editor, tempFileURL, { src: response.publicURL, alt: response.originalName, openViewer: false, + attachmentId: response.id, }); }); }; @@ -117,7 +146,7 @@ export const handleImagePaste = ( type UploadFileFn = (file: File, view: Editor, pos: number) => void; export const createFileUpload = - ({ validateFn, onUpload }: ImageUploadOptions): UploadFileFn => + ({ validateFn, onUpload }: ImageUploadOptionsExtend): UploadFileFn => (file, editor, pos) => { // check if the file is an image const validated = validateFn?.(file) as unknown as boolean; @@ -139,6 +168,7 @@ export const createFileUpload = src: tempFileURL, alt: file.name, uploading: true, + progress: 0, }, }, { @@ -155,12 +185,21 @@ export const createFileUpload = .focus() .run(); - onUpload(file).then((response: any) => { + onUpload(file, (progress) => { + updateNodeAttrs(editor, tempFileURL, { + src: tempFileURL, + uploading: true, + progress, + openViewer: false, + alt: file.name, + }); + }).then((response: any) => { updateNodeAttrs(editor, tempFileURL, { src: response.publicURL, alt: response.originalName, size: response.size, type: response.fileType, + attachmentId: response.id, }); }); }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e4fb357..89a415a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -415,6 +415,9 @@ importers: '@tiptap/core': specifier: ^2.3.0 version: 2.6.6(@tiptap/pm@2.6.6) + '@tiptap/extension-mention': + specifier: ^2.10.3 + version: 2.10.3(@tiptap/core@2.6.6(@tiptap/pm@2.6.6))(@tiptap/pm@2.6.6)(@tiptap/suggestion@2.6.6(@tiptap/core@2.6.6(@tiptap/pm@2.6.6))(@tiptap/pm@2.6.6)) '@tiptap/react': specifier: ^2.5.4 version: 2.6.6(@tiptap/core@2.6.6(@tiptap/pm@2.6.6))(@tiptap/pm@2.6.6)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -423,7 +426,7 @@ importers: version: 3.20.0(react@18.3.1) ai: specifier: ^3.2.37 - version: 3.3.21(openai@4.57.0(encoding@0.1.13)(zod@3.23.8))(react@18.3.1)(sswr@2.1.0(svelte@4.2.19))(svelte@4.2.19)(vue@3.4.38(typescript@5.5.4))(zod@3.23.8) + version: 3.3.21(openai@4.57.0(zod@3.23.8))(react@18.3.1)(sswr@2.1.0(svelte@4.2.19))(svelte@4.2.19)(vue@3.4.38(typescript@5.5.4))(zod@3.23.8) axios: specifier: ^1.6.7 version: 1.7.5(debug@4.3.6) @@ -956,7 +959,7 @@ importers: dependencies: ai: specifier: ^3.2.37 - version: 3.3.21(openai@4.57.0(encoding@0.1.13)(zod@3.23.8))(react@18.3.1)(sswr@2.1.0(svelte@4.2.19))(svelte@4.2.19)(vue@3.4.38(typescript@5.5.4))(zod@3.23.8) + version: 3.3.21(openai@4.57.0(zod@3.23.8))(react@18.3.1)(sswr@2.1.0(svelte@4.2.19))(svelte@4.2.19)(vue@3.4.38(typescript@5.5.4))(zod@3.23.8) class-validator: specifier: 0.14.1 version: 0.14.1 @@ -1080,7 +1083,7 @@ importers: version: 1.12.12(@types/react@18.3.5)(react@18.3.1) ai: specifier: ^3.2.37 - version: 3.3.21(openai@4.57.0(encoding@0.1.13)(zod@3.23.8))(react@18.3.1)(sswr@2.1.0(svelte@4.2.19))(svelte@4.2.19)(vue@3.4.38(typescript@5.5.4))(zod@3.23.8) + version: 3.3.21(openai@4.57.0(zod@3.23.8))(react@18.3.1)(sswr@2.1.0(svelte@4.2.19))(svelte@4.2.19)(vue@3.4.38(typescript@5.5.4))(zod@3.23.8) axios: specifier: ^1.6.7 version: 1.7.5(debug@4.3.6) @@ -4708,6 +4711,13 @@ packages: peerDependencies: '@tiptap/core': ^2.6.6 + '@tiptap/extension-mention@2.10.3': + resolution: {integrity: sha512-h0+BrTS2HdjMfsuy6zkFIqmVGYL8w3jIG0gYaDHjWwwe/Lf2BDgOu3bZWcSr/3bKiJIwwzpOJrXssqta4TZ0yQ==} + peerDependencies: + '@tiptap/core': ^2.7.0 + '@tiptap/pm': ^2.7.0 + '@tiptap/suggestion': ^2.7.0 + '@tiptap/extension-ordered-list@2.6.6': resolution: {integrity: sha512-AJwyfLXIi7iUGnK5twJbwdVVpQyh7fU6OK75h1AwDztzsOcoPcxtffDlZvUOd4ZtwuyhkzYqVkeI0f+abTWZTw==} peerDependencies: @@ -17186,6 +17196,12 @@ snapshots: dependencies: '@tiptap/core': 2.6.6(@tiptap/pm@2.6.6) + '@tiptap/extension-mention@2.10.3(@tiptap/core@2.6.6(@tiptap/pm@2.6.6))(@tiptap/pm@2.6.6)(@tiptap/suggestion@2.6.6(@tiptap/core@2.6.6(@tiptap/pm@2.6.6))(@tiptap/pm@2.6.6))': + dependencies: + '@tiptap/core': 2.6.6(@tiptap/pm@2.6.6) + '@tiptap/pm': 2.6.6 + '@tiptap/suggestion': 2.6.6(@tiptap/core@2.6.6(@tiptap/pm@2.6.6))(@tiptap/pm@2.6.6) + '@tiptap/extension-ordered-list@2.6.6(@tiptap/core@2.6.6(@tiptap/pm@2.6.6))': dependencies: '@tiptap/core': 2.6.6(@tiptap/pm@2.6.6) @@ -18193,7 +18209,7 @@ snapshots: clean-stack: 2.2.0 indent-string: 4.0.0 - ai@3.3.21(openai@4.57.0(encoding@0.1.13)(zod@3.23.8))(react@18.3.1)(sswr@2.1.0(svelte@4.2.19))(svelte@4.2.19)(vue@3.4.38(typescript@5.5.4))(zod@3.23.8): + ai@3.3.21(openai@4.57.0(zod@3.23.8))(react@18.3.1)(sswr@2.1.0(svelte@4.2.19))(svelte@4.2.19)(vue@3.4.38(typescript@5.5.4))(zod@3.23.8): dependencies: '@ai-sdk/provider': 0.0.22 '@ai-sdk/provider-utils': 1.0.17(zod@3.23.8) @@ -20079,7 +20095,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.2(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.8.2(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3