diff --git a/.changeset/thin-dragons-deny.md b/.changeset/thin-dragons-deny.md new file mode 100644 index 0000000000..826becdeaa --- /dev/null +++ b/.changeset/thin-dragons-deny.md @@ -0,0 +1,5 @@ +--- +'@udecode/plate-media': patch +--- + +Fix error message. diff --git a/apps/www/content/docs/components/changelog.mdx b/apps/www/content/docs/components/changelog.mdx index 3131b6e2e0..da934d4024 100644 --- a/apps/www/content/docs/components/changelog.mdx +++ b/apps/www/content/docs/components/changelog.mdx @@ -13,7 +13,8 @@ Use the [CLI](https://platejs.org/docs/components/cli) to install the latest ver ### November 14 #16.7 -Add `ToolbarSplitButton` in `toolbar.tsx`. +Add `ToolbarSplitButton`, `ToolbarSplitButtonPrimary`, `ToolbarSplitButtonSecondary` in `toolbar.tsx`. +Refactor `media-toolbar-button.tsx` to use the new split button. ### November 13 #16.6 diff --git a/apps/www/content/docs/media-placeholder.mdx b/apps/www/content/docs/media-placeholder.mdx index 9babde21a1..b2c3ec3651 100644 --- a/apps/www/content/docs/media-placeholder.mdx +++ b/apps/www/content/docs/media-placeholder.mdx @@ -2,8 +2,10 @@ title: Media Placeholder description: Media placeholders to be used as clickable placeholders for various media types (image, video, audio, file). docs: - - route: https://pro.platejs.org/docs/components/media-placeholder-element + - route: components/media-placeholder-element title: Media Placeholder Element + - route: components/media-upload-toast + title: Media Upload Toast --- @@ -22,6 +24,7 @@ npm install @udecode/plate-media ``` ## Usage +How to configuration the backend see [Upload](/docs/upload). ```tsx import { @@ -37,7 +40,14 @@ import { ```tsx const plugins = [ // ...otherPlugins, - PlaceholderPlugin, + PlaceholderPlugin.configure({ + options: { + disableEmptyPlaceholder: true, + }, + render: { + afterEditable: () => , + }, + }), ]; ``` @@ -48,17 +58,111 @@ const components = { }; ``` -- [MediaPlaceholderElement](https://pro.platejs.org/docs/components/media-placeholder-element) (Plus) + +## UploadOptions + +### uploadConfig + +Configuration for different file types: + +- You can use this option to configure upload limits for each file type, including: + + - Maximum file count (e.g. `maxFileCount: 1`) + - Maximum file size (e.g. `maxFileSize: '8MB'`) + - Minimum file count (e.g. `minFileCount: 1`) + - mediaType: Used for passing to the media-placeholder-elements file to distinguish between different file types and their progress bar styles. + + default configuration: + + ```tsx + uploadConfig: { + audio: { + maxFileCount: 1, + maxFileSize: '8MB', + mediaType: AudioPlugin.key, + minFileCount: 1, + }, + blob: { + maxFileCount: 1, + maxFileSize: '8MB', + mediaType: FilePlugin.key, + minFileCount: 1, + }, + image: { + maxFileCount: 3, + maxFileSize: '4MB', + mediaType: ImagePlugin.key, + minFileCount: 1, + }, + pdf: { + maxFileCount: 1, + maxFileSize: '4MB', + mediaType: FilePlugin.key, + minFileCount: 1, + }, + text: { + maxFileCount: 1, + maxFileSize: '64KB', + mediaType: FilePlugin.key, + minFileCount: 1, + }, + video: { + maxFileCount: 1, + maxFileSize: '16MB', + mediaType: VideoPlugin.key, + minFileCount: 1, + }, + }, + ``` + + here is all allowed file types (keys for `uploadConfig`): + + ```tsx + export const ALLOWED_FILE_TYPES = [ + 'image', + 'video', + 'audio', + 'pdf', + 'text', + 'blob', + ] as const; + ``` + +### disableEmptyPlaceholder + +`boolean` (default: `false`) + +Disable empty placeholder when no file is selected. + +### disableFileDrop + +`boolean` (default: `false`) + +Whether we can undo to the placeholder after the file upload is complete. + +### maxFileCount + +`number` (default: `5`) + +Maximum number of files that can be uploaded at once. + +### multiple + +`boolean` (default: `true`) + +Whether multiple files can be uploaded in one time. ## Examples + + ### Plate UI -Work in progress. +Refer to the preview above. ### Plate Plus - + ## Plugins diff --git a/apps/www/content/docs/upload.mdx b/apps/www/content/docs/upload.mdx index 750cf37bdd..c2932814bc 100644 --- a/apps/www/content/docs/upload.mdx +++ b/apps/www/content/docs/upload.mdx @@ -6,32 +6,27 @@ docs: title: Upload --- +### UploadThing Integration - +Make sure you have install the [media-placeholder-element](/docs/components/media-placeholder-element) component and all the dependencies. -{/* ### UploadThing Integration +Set `UPLOADTHING_TOKEN` in your .env file [get one here](https://uploadthing.com/dashboard). -This component uses UploadThing for file uploads. UploadThing provides a simple and efficient way to handle file uploads in your application. -To use UploadThing: +### Using your own backend -1. Set up an UploadThing account and configure your upload endpoints. -2. Install the UploadThing client in your project: +Remove this two folder `lib/uploadthing` and `/api/uploadthing`. -```bash -npm install uploadthing -``` - -3. Configure the UploadThing client in your application. - -For more details on setting up UploadThing, refer to their [documentation](https://docs.uploadthing.com/). */} +Then impelement a similar hooks like `useUploadFile` using your own backend. ## Examples + + ### Plate UI Work in progress. ### Plate Plus - \ No newline at end of file + diff --git a/apps/www/public/r/index.json b/apps/www/public/r/index.json index 75aafb645a..ea03795cd7 100644 --- a/apps/www/public/r/index.json +++ b/apps/www/public/r/index.json @@ -30,6 +30,67 @@ "registryDependencies": [], "type": "registry:ui" }, + { + "dependencies": [ + "@udecode/plate-media", + "use-file-picker", + "@uploadthing/react@7.1.0", + "uploadthing@7.2.0", + "zod", + "sonner" + ], + "doc": { + "description": "A placeholder for media files.", + "docs": [ + { + "route": "/docs/media-placeholder", + "title": "Media Placeholder" + } + ], + "examples": [ + "media-demo", + "media-toolbar-pro" + ] + }, + "files": [ + { + "path": "plate-ui/media-placeholder-element.tsx", + "type": "registry:ui" + }, + { + "path": "lib/uploadthing/uploadthing.ts", + "type": "registry:ui" + } + ], + "name": "media-placeholder-element", + "registryDependencies": [], + "type": "registry:ui" + }, + { + "dependencies": [], + "doc": { + "description": "A toast for media uploads.", + "docs": [ + { + "route": "/docs/media-placeholder", + "title": "Media Placeholder" + } + ], + "examples": [ + "media-demo", + "upload-pro" + ] + }, + "files": [ + { + "path": "plate-ui/media-upload-toast.tsx", + "type": "registry:ui" + } + ], + "name": "media-upload-toast", + "registryDependencies": [], + "type": "registry:ui" + }, { "dependencies": [], "doc": { diff --git a/apps/www/public/r/styles/default/media-placeholder-element.json b/apps/www/public/r/styles/default/media-placeholder-element.json new file mode 100644 index 0000000000..3df8fe8995 --- /dev/null +++ b/apps/www/public/r/styles/default/media-placeholder-element.json @@ -0,0 +1,40 @@ +{ + "dependencies": [ + "@udecode/plate-media", + "use-file-picker", + "@uploadthing/react@7.1.0", + "uploadthing@7.2.0", + "zod", + "sonner" + ], + "doc": { + "description": "A placeholder for media files.", + "docs": [ + { + "route": "/docs/media-placeholder", + "title": "Media Placeholder" + } + ], + "examples": [ + "media-demo", + "media-toolbar-pro" + ] + }, + "files": [ + { + "content": "'use client';\n\nimport React, { useCallback, useEffect, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\n\nimport type { TPlaceholderElement } from '@udecode/plate-media';\n\nimport { cn } from '@udecode/cn';\nimport {\n insertNodes,\n removeNodes,\n withoutSavingHistory,\n} from '@udecode/plate-common';\nimport {\n findNodePath,\n useEditorPlugin,\n withHOC,\n withRef,\n} from '@udecode/plate-common/react';\nimport {\n AudioPlugin,\n FilePlugin,\n ImagePlugin,\n PlaceholderPlugin,\n PlaceholderProvider,\n VideoPlugin,\n updateUploadHistory,\n} from '@udecode/plate-media/react';\nimport { AudioLines, FileUp, Film, ImageIcon } from 'lucide-react';\nimport { useFilePicker } from 'use-file-picker';\n\nimport { useUploadFile } from '../lib/uploadthing';\nimport { PlateElement } from './plate-element';\nimport { Spinner } from './spinner';\n\nconst CONTENT: Record<\n string,\n {\n accept: string[];\n content: ReactNode;\n icon: ReactNode;\n }\n> = {\n [AudioPlugin.key]: {\n accept: ['audio/*'],\n content: 'Add an audio file',\n icon: ,\n },\n [FilePlugin.key]: {\n accept: ['*'],\n content: 'Add a file',\n icon: ,\n },\n [ImagePlugin.key]: {\n accept: ['image/*'],\n content: 'Add an image',\n icon: ,\n },\n [VideoPlugin.key]: {\n accept: ['video/*'],\n content: 'Add a video',\n icon: ,\n },\n};\n\nexport const MediaPlaceholderElement = withHOC(\n PlaceholderProvider,\n withRef(\n ({ children, className, editor, nodeProps, ...props }, ref) => {\n const element = props.element as TPlaceholderElement;\n\n const { api } = useEditorPlugin(PlaceholderPlugin);\n\n const { isUploading, progress, uploadFile, uploadedFile, uploadingFile } =\n useUploadFile();\n\n const loading = isUploading && uploadingFile;\n\n const currentContent = CONTENT[element.mediaType];\n\n const isImage = element.mediaType === ImagePlugin.key;\n\n const imageRef = useRef(null);\n\n const { openFilePicker } = useFilePicker({\n accept: currentContent.accept,\n multiple: true,\n onFilesSelected: ({ plainFiles: updatedFiles }) => {\n const firstFile = updatedFiles[0];\n const restFiles = updatedFiles.slice(1);\n\n replaceCurrentPlaceholder(firstFile);\n\n restFiles.length > 0 && (editor as any).tf.insert.media(restFiles);\n },\n });\n\n const replaceCurrentPlaceholder = useCallback(\n (file: File) => {\n void uploadFile(file);\n api.placeholder.addUploadingFile(element.id as string, file);\n },\n [api.placeholder, element.id, uploadFile]\n );\n\n useEffect(() => {\n if (!uploadedFile) return;\n\n const path = findNodePath(editor, element);\n\n withoutSavingHistory(editor, () => {\n removeNodes(editor, { at: path });\n\n const node = {\n children: [{ text: '' }],\n initialHeight: imageRef.current?.height,\n initialWidth: imageRef.current?.width,\n isUpload: true,\n name: element.mediaType === FilePlugin.key ? uploadedFile.name : '',\n placeholderId: element.id as string,\n type: element.mediaType!,\n url: uploadedFile.url,\n };\n\n insertNodes(editor, node, { at: path });\n\n updateUploadHistory(editor, node);\n });\n\n api.placeholder.removeUploadingFile(element.id as string);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [uploadedFile, element.id]);\n\n // React dev mode will call useEffect twice\n const isReplaced = useRef(false);\n /** Paste and drop */\n useEffect(() => {\n if (isReplaced.current) return;\n\n isReplaced.current = true;\n const currentFiles = api.placeholder.getUploadingFile(\n element.id as string\n );\n\n if (!currentFiles) return;\n\n replaceCurrentPlaceholder(currentFiles);\n\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [isReplaced]);\n\n return (\n \n {(!loading || !isImage) && (\n !loading && openFilePicker()}\n contentEditable={false}\n >\n
\n {currentContent.icon}\n
\n
\n
\n {loading ? uploadingFile?.name : currentContent.content}\n
\n\n {loading && !isImage && (\n
\n
{formatBytes(uploadingFile?.size ?? 0)}
\n
\n
\n \n {progress ?? 0}%\n
\n
\n )}\n
\n \n )}\n\n {isImage && loading && (\n \n )}\n\n {children}\n \n );\n }\n )\n);\n\nexport function ImageProgress({\n className,\n file,\n imageRef,\n progress = 0,\n}: {\n file: File;\n className?: string;\n imageRef?: React.RefObject;\n progress?: number;\n}) {\n const [objectUrl, setObjectUrl] = useState(null);\n\n useEffect(() => {\n const url = URL.createObjectURL(file);\n setObjectUrl(url);\n\n return () => {\n URL.revokeObjectURL(url);\n };\n }, [file]);\n\n if (!objectUrl) {\n return null;\n }\n\n return (\n
\n \n {progress < 100 && (\n
\n \n \n {Math.round(progress)}%\n \n
\n )}\n
\n );\n}\n\nexport function formatBytes(\n bytes: number,\n opts: {\n decimals?: number;\n sizeType?: 'accurate' | 'normal';\n } = {}\n) {\n const { decimals = 0, sizeType = 'normal' } = opts;\n\n const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];\n const accurateSizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB'];\n\n if (bytes === 0) return '0 Byte';\n\n const i = Math.floor(Math.log(bytes) / Math.log(1024));\n\n return `${(bytes / Math.pow(1024, i)).toFixed(decimals)} ${\n sizeType === 'accurate'\n ? (accurateSizes[i] ?? 'Bytest')\n : (sizes[i] ?? 'Bytes')\n }`;\n}\n", + "path": "plate-ui/media-placeholder-element.tsx", + "target": "components/plate-ui/media-placeholder-element.tsx", + "type": "registry:ui" + }, + { + "content": "import * as React from 'react';\n\nimport { isRedirectError } from 'next/dist/client/components/redirect';\nimport { toast } from 'sonner';\nimport { z } from 'zod';\n\nexport interface UploadedFile {\n key: string;\n appUrl: string;\n name: string;\n size: number;\n type: string;\n url: string;\n}\n\nexport function useUploadFile() {\n const [uploadedFile, setUploadedFile] = React.useState();\n const [uploadingFile, setUploadingFile] = React.useState();\n const [progress, setProgress] = React.useState(0);\n const [isUploading, setIsUploading] = React.useState(false);\n\n async function uploadThing(file: File) {\n setIsUploading(true);\n setUploadingFile(file);\n\n try {\n // Mock upload for unauthenticated users\n // toast.info('User not logged in. Mocking upload process.');\n const mockUploadedFile = {\n key: 'mock-key-0',\n appUrl: `https://mock-app-url.com/${file.name}`,\n name: file.name,\n size: file.size,\n type: file.type,\n url: URL.createObjectURL(file),\n } as UploadedFile;\n\n // Simulate upload progress\n let progress = 0;\n\n const simulateProgress = async () => {\n while (progress < 100) {\n await new Promise((resolve) => setTimeout(resolve, 100));\n progress += 2;\n setProgress(Math.min(progress, 100));\n }\n };\n\n await simulateProgress();\n\n setUploadedFile(mockUploadedFile);\n\n return mockUploadedFile;\n } catch (error) {\n const errorMessage = getErrorMessage(error);\n\n const message =\n errorMessage.length > 0\n ? errorMessage\n : 'Something went wrong, please try again later.';\n\n toast.error(message);\n } finally {\n setProgress(0);\n setIsUploading(false);\n setUploadingFile(undefined);\n }\n }\n\n return {\n isUploading,\n progress,\n uploadFile: uploadThing,\n uploadedFile,\n uploadingFile,\n };\n}\n\nexport function getErrorMessage(err: unknown) {\n const unknownError = 'Something went wrong, please try again later.';\n\n if (err instanceof z.ZodError) {\n const errors = err.issues.map((issue) => {\n return issue.message;\n });\n\n return errors.join('\\n');\n } else if (err instanceof Error) {\n return err.message;\n } else if (isRedirectError(err)) {\n throw err;\n } else {\n return unknownError;\n }\n}\n\nexport function showErrorToast(err: unknown) {\n const errorMessage = getErrorMessage(err);\n\n return toast.error(errorMessage);\n}\n", + "path": "lib/uploadthing/uploadthing.ts", + "target": "components/plate-ui/uploadthing.ts", + "type": "registry:ui" + } + ], + "name": "media-placeholder-element", + "registryDependencies": [], + "type": "registry:ui" +} \ No newline at end of file diff --git a/apps/www/public/r/styles/default/media-toolbar-button.json b/apps/www/public/r/styles/default/media-toolbar-button.json index 2961218f6e..d7f1d12617 100644 --- a/apps/www/public/r/styles/default/media-toolbar-button.json +++ b/apps/www/public/r/styles/default/media-toolbar-button.json @@ -17,7 +17,7 @@ }, "files": [ { - "content": "'use client';\n\nimport React, { useCallback, useEffect, useState } from 'react';\n\nimport type { DropdownMenuProps } from '@radix-ui/react-dropdown-menu';\n\nimport { cn } from '@udecode/cn';\nimport { useEditorRef } from '@udecode/plate-core/react';\nimport {\n AudioPlugin,\n FilePlugin,\n ImagePlugin,\n VideoPlugin,\n} from '@udecode/plate-media/react';\nimport { insertNodes } from '@udecode/slate';\nimport { focusEditor } from '@udecode/slate-react';\nimport {\n AudioLinesIcon,\n FileUpIcon,\n FilmIcon,\n ImageIcon,\n LinkIcon,\n} from 'lucide-react';\nimport { useFilePicker } from 'use-file-picker';\n\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n} from './alert-dialog';\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuTrigger,\n useOpenState,\n} from './dropdown-menu';\nimport { Input } from './input';\nimport {\n ToolbarSplitButton,\n ToolbarSplitButtonPrimary,\n ToolbarSplitButtonSecondary,\n} from './toolbar';\nconst MEDIA_CONFIG: Record<\n string,\n {\n accept: string[];\n icon: React.ReactNode;\n title: string;\n tooltip: string;\n }\n> = {\n [AudioPlugin.key]: {\n accept: ['audio/*'],\n icon: ,\n title: 'Insert Audio',\n tooltip: 'Audio',\n },\n [FilePlugin.key]: {\n accept: ['*'],\n icon: ,\n title: 'Insert File',\n tooltip: 'File',\n },\n [ImagePlugin.key]: {\n accept: ['image/*'],\n icon: ,\n title: 'Insert Image',\n tooltip: 'Image',\n },\n [VideoPlugin.key]: {\n accept: ['video/*'],\n icon: ,\n title: 'Insert Video',\n tooltip: 'Video',\n },\n};\n\nexport function MediaToolbarButton({\n children,\n nodeType,\n ...props\n}: DropdownMenuProps & { nodeType: string }) {\n const currentConfig = MEDIA_CONFIG[nodeType];\n\n const editor = useEditorRef();\n const openState = useOpenState();\n\n const { openFilePicker } = useFilePicker({\n accept: currentConfig.accept,\n multiple: true,\n onFilesSelected: ({ plainFiles: updatedFiles }) => {\n (editor as any).tf.insert.media(updatedFiles);\n },\n });\n\n const [dialogOpen, setDialogOpen] = useState(false);\n\n const [url, setUrl] = useState('');\n\n const embedMedia = useCallback(() => {\n setDialogOpen(false);\n insertNodes(editor, {\n children: [{ text: '' }],\n name: nodeType === FilePlugin.key ? url.split('/').pop() : undefined,\n type: nodeType,\n url,\n });\n }, [editor, nodeType, url]);\n\n useEffect(() => {\n if (!dialogOpen) {\n focusEditor(editor);\n setUrl('');\n }\n }, [dialogOpen, editor]);\n\n return (\n <>\n \n \n openFilePicker()}\n onMouseDown={(e) => e.preventDefault()}\n tooltip={currentConfig.tooltip}\n >\n {currentConfig.icon}\n \n\n \n \n \n \n\n \n \n openFilePicker()}\n hideIcon\n >\n
\n {currentConfig.icon}\n Upload from computer\n
\n \n setDialogOpen(true)}\n hideIcon\n >\n
\n \n Insert via URL\n
\n \n
\n \n
\n\n \n \n \n {currentConfig.title}\n \n
\n \n URL\n \n setUrl(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter') embedMedia();\n }}\n placeholder=\"\"\n type=\"email\"\n autoFocus\n />\n
\n
\n
\n \n Cancel\n Accept\n \n
\n
\n \n );\n}\n", + "content": "'use client';\n\nimport React, { useCallback, useState } from 'react';\n\nimport type { DropdownMenuProps } from '@radix-ui/react-dropdown-menu';\n\nimport { cn } from '@udecode/cn';\nimport { insertNodes, isUrl } from '@udecode/plate-common';\nimport { useEditorRef } from '@udecode/plate-core/react';\nimport {\n AudioPlugin,\n FilePlugin,\n ImagePlugin,\n VideoPlugin,\n} from '@udecode/plate-media/react';\nimport {\n AudioLinesIcon,\n FileUpIcon,\n FilmIcon,\n ImageIcon,\n LinkIcon,\n} from 'lucide-react';\nimport { toast } from 'sonner';\nimport { useFilePicker } from 'use-file-picker';\n\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n} from './alert-dialog';\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuTrigger,\n useOpenState,\n} from './dropdown-menu';\nimport { Input } from './input';\nimport {\n ToolbarSplitButton,\n ToolbarSplitButtonPrimary,\n ToolbarSplitButtonSecondary,\n} from './toolbar';\nconst MEDIA_CONFIG: Record<\n string,\n {\n accept: string[];\n icon: React.ReactNode;\n title: string;\n tooltip: string;\n }\n> = {\n [AudioPlugin.key]: {\n accept: ['audio/*'],\n icon: ,\n title: 'Insert Audio',\n tooltip: 'Audio',\n },\n [FilePlugin.key]: {\n accept: ['*'],\n icon: ,\n title: 'Insert File',\n tooltip: 'File',\n },\n [ImagePlugin.key]: {\n accept: ['image/*'],\n icon: ,\n title: 'Insert Image',\n tooltip: 'Image',\n },\n [VideoPlugin.key]: {\n accept: ['video/*'],\n icon: ,\n title: 'Insert Video',\n tooltip: 'Video',\n },\n};\n\nexport function MediaToolbarButton({\n children,\n nodeType,\n ...props\n}: DropdownMenuProps & { nodeType: string }) {\n const currentConfig = MEDIA_CONFIG[nodeType];\n\n const editor = useEditorRef();\n const openState = useOpenState();\n\n const { openFilePicker } = useFilePicker({\n accept: currentConfig.accept,\n multiple: true,\n onFilesSelected: ({ plainFiles: updatedFiles }) => {\n (editor as any).tf.insert.media(updatedFiles);\n },\n });\n\n const [dialogOpen, setDialogOpen] = useState(false);\n\n const [url, setUrl] = useState('');\n\n const embedMedia = useCallback(() => {\n if (!isUrl(url)) return toast.error('Invalid URL');\n\n setDialogOpen(false);\n insertNodes(editor, {\n children: [{ text: '' }],\n name: nodeType === FilePlugin.key ? url.split('/').pop() : undefined,\n type: nodeType,\n url,\n });\n }, [url, editor, nodeType]);\n\n return (\n <>\n \n \n openFilePicker()}\n onMouseDown={(e) => e.preventDefault()}\n tooltip={currentConfig.tooltip}\n >\n {currentConfig.icon}\n \n\n \n \n \n \n\n \n \n openFilePicker()}\n hideIcon\n >\n
\n {currentConfig.icon}\n Upload from computer\n
\n \n setDialogOpen(true)}\n hideIcon\n >\n
\n \n Insert via URL\n
\n \n
\n \n
\n\n {\n setDialogOpen(value);\n setUrl('');\n }}\n >\n \n \n {currentConfig.title}\n \n \n URL\n \n setUrl(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === 'Enter') embedMedia();\n }}\n placeholder=\"\"\n type=\"email\"\n autoFocus\n />\n \n \n \n Cancel\n {\n e.preventDefault();\n embedMedia();\n }}\n >\n Accept\n \n \n \n \n \n );\n}\n", "path": "plate-ui/media-toolbar-button.tsx", "target": "components/plate-ui/media-toolbar-button.tsx", "type": "registry:ui" diff --git a/apps/www/public/r/styles/default/media-upload-toast.json b/apps/www/public/r/styles/default/media-upload-toast.json new file mode 100644 index 0000000000..453828c1a1 --- /dev/null +++ b/apps/www/public/r/styles/default/media-upload-toast.json @@ -0,0 +1,27 @@ +{ + "dependencies": [], + "doc": { + "description": "A toast for media uploads.", + "docs": [ + { + "route": "/docs/media-placeholder", + "title": "Media Placeholder" + } + ], + "examples": [ + "media-demo", + "upload-pro" + ] + }, + "files": [ + { + "content": "'use client';\n\nimport { useEffect } from 'react';\n\nimport { useEditorRef } from '@udecode/plate-common/react';\nimport { PlaceholderPlugin, UploadErrorCode } from '@udecode/plate-media/react';\nimport { toast } from 'sonner';\n\nexport const useUploadErrorToast = () => {\n const editor = useEditorRef();\n\n const uploadError = editor.useOption(PlaceholderPlugin, 'error');\n\n useEffect(() => {\n if (!uploadError) return;\n\n const { code, data } = uploadError;\n\n switch (code) {\n case UploadErrorCode.INVALID_FILE_SIZE: {\n toast.error(\n `The size of files ${data.files\n .map((f) => f.name)\n .join(', ')} is invalid`\n );\n\n break;\n }\n case UploadErrorCode.INVALID_FILE_TYPE: {\n toast.error(\n `The type of files ${data.files\n .map((f) => f.name)\n .join(', ')} is invalid`\n );\n\n break;\n }\n case UploadErrorCode.TOO_LARGE: {\n toast.error(\n `The size of files ${data.files\n .map((f) => f.name)\n .join(', ')} is too large than ${data.maxFileSize}`\n );\n\n break;\n }\n case UploadErrorCode.TOO_LESS_FILES: {\n toast.error(\n `The mini um number of files is ${data.minFileCount} for ${data.fileType}`\n );\n\n break;\n }\n case UploadErrorCode.TOO_MANY_FILES: {\n toast.error(\n `The maximum number of files is ${data.maxFileCount} ${\n data.fileType ? `for ${data.fileType}` : ''\n }`\n );\n\n break;\n }\n }\n }, [uploadError]);\n};\n\nexport const MediaUploadToast = () => {\n useUploadErrorToast();\n\n return null;\n};\n", + "path": "plate-ui/media-upload-toast.tsx", + "target": "components/plate-ui/media-upload-toast.tsx", + "type": "registry:ui" + } + ], + "name": "media-upload-toast", + "registryDependencies": [], + "type": "registry:ui" +} \ No newline at end of file diff --git a/apps/www/src/__registry__/index.tsx b/apps/www/src/__registry__/index.tsx index a160dfb364..0de2b81292 100644 --- a/apps/www/src/__registry__/index.tsx +++ b/apps/www/src/__registry__/index.tsx @@ -17,6 +17,30 @@ export const Index: Record = { subcategory: "", chunks: [] }, + "media-placeholder-element": { + name: "media-placeholder-element", + description: "", + type: "registry:ui", + registryDependencies: [], + files: ["registry/default/plate-ui/media-placeholder-element.tsx","registry/default/lib/uploadthing/uploadthing.ts"], + component: React.lazy(() => import("@/registry/default/plate-ui/media-placeholder-element.tsx")), + source: "", + category: "", + subcategory: "", + chunks: [] + }, + "media-upload-toast": { + name: "media-upload-toast", + description: "", + type: "registry:ui", + registryDependencies: [], + files: ["registry/default/plate-ui/media-upload-toast.tsx"], + component: React.lazy(() => import("@/registry/default/plate-ui/media-upload-toast.tsx")), + source: "", + category: "", + subcategory: "", + chunks: [] + }, "blockquote-element": { name: "blockquote-element", description: "", diff --git a/apps/www/src/config/customizer-components.ts b/apps/www/src/config/customizer-components.ts index d27d397794..e91e78ab44 100644 --- a/apps/www/src/config/customizer-components.ts +++ b/apps/www/src/config/customizer-components.ts @@ -220,6 +220,11 @@ export const customizerComponents = { label: 'Element', title: 'Media Embed', }, + mediaPlaceholderElement: { + href: '/docs/components/media-placeholder-element', + label: 'Element', + title: 'Media Placeholder', + }, mediaPopover: { href: '/docs/components/media-popover', title: 'Media Popover', @@ -228,6 +233,10 @@ export const customizerComponents = { href: '/docs/components/media-toolbar-button', title: 'Media Toolbar Button', }, + mediaUploadToast: { + href: '/docs/components/media-upload-toast', + title: 'Media Upload Toast', + }, mentionElement: { href: '/docs/components/mention-element', label: 'Element', diff --git a/apps/www/src/config/customizer-items.ts b/apps/www/src/config/customizer-items.ts index 7d95f55fdc..55befe2422 100644 --- a/apps/www/src/config/customizer-items.ts +++ b/apps/www/src/config/customizer-items.ts @@ -46,7 +46,11 @@ import { LineHeightPlugin } from '@udecode/plate-line-height/react'; import { LinkPlugin } from '@udecode/plate-link/react'; import { TodoListPlugin } from '@udecode/plate-list/react'; import { MarkdownPlugin } from '@udecode/plate-markdown'; -import { ImagePlugin, MediaEmbedPlugin } from '@udecode/plate-media/react'; +import { + ImagePlugin, + MediaEmbedPlugin, + PlaceholderPlugin, +} from '@udecode/plate-media/react'; import { MentionInputPlugin, MentionPlugin, @@ -110,6 +114,14 @@ export type SettingPlugin = { }; export const customizerItems: Record = { + [`media_${PlaceholderPlugin.key}`]: { + id: `media_${PlaceholderPlugin.key}`, + label: 'MediaPlaceholder', + npmPackage: '@udecode/plate-placeholder', + pluginFactory: 'PlaceholderPlugin', + reactImport: true, + route: customizerPlugins.mediaPlaceholder.route, + }, [AIChatPlugin.key]: { id: AIChatPlugin.key, badges: [customizerBadges.handler], diff --git a/apps/www/src/config/customizer-list.ts b/apps/www/src/config/customizer-list.ts index 57a4c9844c..a266b2d081 100644 --- a/apps/www/src/config/customizer-list.ts +++ b/apps/www/src/config/customizer-list.ts @@ -42,7 +42,11 @@ import { LineHeightPlugin } from '@udecode/plate-line-height/react'; import { LinkPlugin } from '@udecode/plate-link/react'; import { TodoListPlugin } from '@udecode/plate-list/react'; import { MarkdownPlugin } from '@udecode/plate-markdown'; -import { ImagePlugin, MediaEmbedPlugin } from '@udecode/plate-media/react'; +import { + ImagePlugin, + MediaEmbedPlugin, + PlaceholderPlugin, +} from '@udecode/plate-media/react'; import { MentionPlugin } from '@udecode/plate-mention/react'; import { NodeIdPlugin } from '@udecode/plate-node-id'; import { NormalizeTypesPlugin } from '@udecode/plate-normalizers'; @@ -88,6 +92,7 @@ export const customizerList = [ customizerItems.heading, customizerItems.list, customizerItems[MediaEmbedPlugin.key], + customizerItems[`media_${PlaceholderPlugin.key}`], customizerItems[MentionPlugin.key], customizerItems[ParagraphPlugin.key], customizerItems[TablePlugin.key], @@ -175,6 +180,8 @@ export const orderedPluginKeys = [ 'list', ImagePlugin.key, MediaEmbedPlugin.key, + PlaceholderPlugin.key, + CaptionPlugin.key, MentionPlugin.key, TablePlugin.key, diff --git a/apps/www/src/config/customizer-plugins.ts b/apps/www/src/config/customizer-plugins.ts index 1f12dbb832..a8fb320d15 100644 --- a/apps/www/src/config/customizer-plugins.ts +++ b/apps/www/src/config/customizer-plugins.ts @@ -78,7 +78,10 @@ import { kbdValue } from '@/plate/demo/values/kbdValue'; import { lineHeightValue } from '@/plate/demo/values/lineHeightValue'; import { linkValue } from '@/plate/demo/values/linkValue'; import { listValue, todoListValue } from '@/plate/demo/values/listValue'; -import { mediaValue } from '@/plate/demo/values/mediaValue'; +import { + mediaPlaceholderValue, + mediaValue, +} from '@/plate/demo/values/mediaValue'; import { mentionValue } from '@/plate/demo/values/mentionValue'; import { placeholderValue } from '@/plate/demo/values/placeholderValue'; import { singleLineValue } from '@/plate/demo/values/singleLineValue'; @@ -343,6 +346,12 @@ export const customizerPlugins = { route: '/docs/media', value: mediaValue, }, + mediaPlaceholder: { + id: 'media-placeholder', + label: 'MediaPlaceholder', + route: '/docs/media-placeholder', + value: mediaPlaceholderValue, + }, mention: { id: 'mention', label: 'Mention', diff --git a/apps/www/src/config/descriptions.ts b/apps/www/src/config/descriptions.ts index 395aea6c17..8096f934f8 100644 --- a/apps/www/src/config/descriptions.ts +++ b/apps/www/src/config/descriptions.ts @@ -40,7 +40,11 @@ import { LineHeightPlugin } from '@udecode/plate-line-height/react'; import { LinkPlugin } from '@udecode/plate-link/react'; import { TodoListPlugin } from '@udecode/plate-list/react'; import { MarkdownPlugin } from '@udecode/plate-markdown'; -import { ImagePlugin, MediaEmbedPlugin } from '@udecode/plate-media/react'; +import { + ImagePlugin, + MediaEmbedPlugin, + PlaceholderPlugin, +} from '@udecode/plate-media/react'; import { MentionPlugin } from '@udecode/plate-mention/react'; import { NodeIdPlugin } from '@udecode/plate-node-id'; import { NormalizeTypesPlugin } from '@udecode/plate-normalizers'; @@ -61,6 +65,7 @@ import { FixedToolbarPlugin } from '@/registry/default/components/editor/plugins import { FloatingToolbarPlugin } from '@/registry/default/components/editor/plugins/floating-toolbar-plugin'; export const descriptions: Record = { + [`media_${PlaceholderPlugin.key}`]: 'Add placeholder to your media blocks.', [AIChatPlugin.key]: 'AI menu with commands, streaming responses in a preview or directly into the editor.', [AIPlugin.key]: 'AI transforms.', diff --git a/apps/www/src/lib/plate/demo/values/mediaValue.tsx b/apps/www/src/lib/plate/demo/values/mediaValue.tsx index 0fdfd3889c..d95f82cb85 100644 --- a/apps/www/src/lib/plate/demo/values/mediaValue.tsx +++ b/apps/www/src/lib/plate/demo/values/mediaValue.tsx @@ -19,9 +19,57 @@ export const imageValue: any = ( ); +export const mediaPlaceholderValue: any = ( + + Upload + + Our editor supports various media types for upload, including images, + videos, audio, and files. + + + + + + Real-time upload status and progress tracking + + + + + + Configurable file size limits and batch upload settings + + + + + + Clear error messages for any upload issues + + + Try it now - drag an image from your desktop or click the upload button in + the toolbar + + +); + export const mediaValue: any = ( {imageValue} + {mediaPlaceholderValue} Embed Embed various types of content, such as videos and tweets: diff --git a/apps/www/src/lib/plate/demo/values/usePlaygroundValue.ts b/apps/www/src/lib/plate/demo/values/usePlaygroundValue.ts index 02efcf7658..f0a16a8b39 100644 --- a/apps/www/src/lib/plate/demo/values/usePlaygroundValue.ts +++ b/apps/www/src/lib/plate/demo/values/usePlaygroundValue.ts @@ -95,7 +95,8 @@ export const usePlaygroundValue = (id?: ValueId): Value => { if (enabled.a) value.push(...linkValue); if (enabled.hr) value.push(...horizontalRuleValue); if (enabled.table) value.push(...tableValue); - if (enabled.img || enabled.media_embed) value.push(...mediaValue); + if (enabled.img || enabled.media_embed || enabled.media_placeholder) + value.push(...mediaValue); if (enabled.column) value.push(...columnValue); if (enabled.mention) value.push(...mentionValue); if (enabled.date) value.push(...dateValue); diff --git a/apps/www/src/registry/default/lib/uploadthing/handle-error.ts b/apps/www/src/registry/default/lib/uploadthing/handle-error.ts deleted file mode 100644 index f04db185ad..0000000000 --- a/apps/www/src/registry/default/lib/uploadthing/handle-error.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { isRedirectError } from 'next/dist/client/components/redirect'; -import { toast } from 'sonner'; -import { z } from 'zod'; - -export function getErrorMessage(err: unknown) { - const unknownError = 'Something went wrong, please try again later.'; - - if (err instanceof z.ZodError) { - const errors = err.issues.map((issue) => { - return issue.message; - }); - - return errors.join('\n'); - } else if (err instanceof Error) { - return err.message; - } else if (isRedirectError(err)) { - throw err; - } else { - return unknownError; - } -} - -export function showErrorToast(err: unknown) { - const errorMessage = getErrorMessage(err); - - return toast.error(errorMessage); -} diff --git a/apps/www/src/registry/default/lib/uploadthing/index.ts b/apps/www/src/registry/default/lib/uploadthing/index.ts index b8e8734c65..1a2665ad75 100644 --- a/apps/www/src/registry/default/lib/uploadthing/index.ts +++ b/apps/www/src/registry/default/lib/uploadthing/index.ts @@ -1,4 +1 @@ -export * from './handle-error'; - - -export * from './use-upload-file'; +export * from './uploadthing' \ No newline at end of file diff --git a/apps/www/src/registry/default/lib/uploadthing/use-upload-file.ts b/apps/www/src/registry/default/lib/uploadthing/uploadthing.ts similarity index 73% rename from apps/www/src/registry/default/lib/uploadthing/use-upload-file.ts rename to apps/www/src/registry/default/lib/uploadthing/uploadthing.ts index 1c708ec341..cb765540fe 100644 --- a/apps/www/src/registry/default/lib/uploadthing/use-upload-file.ts +++ b/apps/www/src/registry/default/lib/uploadthing/uploadthing.ts @@ -1,8 +1,8 @@ import * as React from 'react'; +import { isRedirectError } from 'next/dist/client/components/redirect'; import { toast } from 'sonner'; - -import { getErrorMessage } from './handle-error'; +import { z } from 'zod'; export interface UploadedFile { key: string; @@ -75,3 +75,27 @@ export function useUploadFile() { uploadingFile, }; } + +export function getErrorMessage(err: unknown) { + const unknownError = 'Something went wrong, please try again later.'; + + if (err instanceof z.ZodError) { + const errors = err.issues.map((issue) => { + return issue.message; + }); + + return errors.join('\n'); + } else if (err instanceof Error) { + return err.message; + } else if (isRedirectError(err)) { + throw err; + } else { + return unknownError; + } +} + +export function showErrorToast(err: unknown) { + const errorMessage = getErrorMessage(err); + + return toast.error(errorMessage); +} diff --git a/apps/www/src/registry/default/plate-ui/media-upload-toast.tsx b/apps/www/src/registry/default/plate-ui/media-upload-toast.tsx index ce39cb2e79..e9ce0b7aa2 100644 --- a/apps/www/src/registry/default/plate-ui/media-upload-toast.tsx +++ b/apps/www/src/registry/default/plate-ui/media-upload-toast.tsx @@ -1,3 +1,5 @@ +'use client'; + import { useEffect } from 'react'; import { useEditorRef } from '@udecode/plate-common/react'; diff --git a/apps/www/src/registry/registry-examples.ts b/apps/www/src/registry/registry-examples.ts index 3b82b51bfd..102e1c65ca 100644 --- a/apps/www/src/registry/registry-examples.ts +++ b/apps/www/src/registry/registry-examples.ts @@ -29,12 +29,14 @@ export const proExamples: Registry = [ - Support for various media types: images, videos, audio, and files - Use slash commands for quick insertion - Image-specific features: + - **Better loading animation and image replacement** - Resize using vertical edge bars - Alignment options - Caption support - Expand/collapse view - Easy download - Video-specific features: + - Lazy load - Resize using vertical edge bars - Alignment options - Caption support diff --git a/apps/www/src/registry/registry-ui.ts b/apps/www/src/registry/registry-ui.ts index 83530d89bd..83755e2720 100644 --- a/apps/www/src/registry/registry-ui.ts +++ b/apps/www/src/registry/registry-ui.ts @@ -844,6 +844,40 @@ export const uiNodes: Registry = [ registryDependencies: [], type: 'registry:ui', }, + { + dependencies: [ + '@udecode/plate-media', + 'use-file-picker', + '@uploadthing/react@7.1.0', + 'uploadthing@7.2.0', + 'zod', + 'sonner', + ], + doc: { + description: 'A placeholder for media files.', + docs: [{ route: '/docs/media-placeholder', title: 'Media Placeholder' }], + examples: ['media-demo', 'media-toolbar-pro'], + }, + files: [ + 'plate-ui/media-placeholder-element.tsx', + 'lib/uploadthing/uploadthing.ts', + ], + name: 'media-placeholder-element', + registryDependencies: [], + type: 'registry:ui', + }, + { + dependencies: [], + doc: { + description: 'A toast for media uploads.', + docs: [{ route: '/docs/media-placeholder', title: 'Media Placeholder' }], + examples: ['media-demo', 'upload-pro'], + }, + files: ['plate-ui/media-upload-toast.tsx'], + name: 'media-upload-toast', + registryDependencies: [], + type: 'registry:ui', + }, { dependencies: [], doc: { diff --git a/packages/media/src/react/placeholder/utils/validateFileItem.ts b/packages/media/src/react/placeholder/utils/validateFileItem.ts index 42b94a97dc..6125bbb9fb 100644 --- a/packages/media/src/react/placeholder/utils/validateFileItem.ts +++ b/packages/media/src/react/placeholder/utils/validateFileItem.ts @@ -3,7 +3,7 @@ import type { AllowedFileType } from '../internal/mimes'; import { type FileSize, type MediaItemConfig, UploadErrorCode } from '../type'; import { createUploadError } from './createUploadError'; -import { bytesToFileSize, fileSizeToBytes } from './fileSizeToBytes'; +import { fileSizeToBytes } from './fileSizeToBytes'; export const validateFileItem = ( files: File[], @@ -32,7 +32,7 @@ export const validateFileItem = ( throw createUploadError(UploadErrorCode.TOO_LARGE, { fileType: key, files: [f], - maxFileSize: bytesToFileSize(bytes), + maxFileSize: maxFileSize!, }); } diff --git a/templates/plate-playground-template/src/app/layout.tsx b/templates/plate-playground-template/src/app/layout.tsx index a36cde01c6..7ec0b1587b 100644 --- a/templates/plate-playground-template/src/app/layout.tsx +++ b/templates/plate-playground-template/src/app/layout.tsx @@ -1,21 +1,24 @@ -import type { Metadata } from "next"; -import localFont from "next/font/local"; -import "./globals.css"; +import type { Metadata } from 'next'; + +import localFont from 'next/font/local'; +import { Toaster } from 'sonner'; + +import './globals.css'; const geistSans = localFont({ - src: "./fonts/GeistVF.woff", - variable: "--font-geist-sans", - weight: "100 900", + src: './fonts/GeistVF.woff', + variable: '--font-geist-sans', + weight: '100 900', }); const geistMono = localFont({ - src: "./fonts/GeistMonoVF.woff", - variable: "--font-geist-mono", - weight: "100 900", + src: './fonts/GeistMonoVF.woff', + variable: '--font-geist-mono', + weight: '100 900', }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + description: 'Generated by create next app', + title: 'Create Next App', }; export default function RootLayout({ @@ -29,6 +32,7 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`} > {children} + ); diff --git a/templates/plate-playground-template/src/components/editor/use-chat.tsx b/templates/plate-playground-template/src/components/editor/use-chat.tsx index 93bbd9438d..d8e54488b4 100644 --- a/templates/plate-playground-template/src/components/editor/use-chat.tsx +++ b/templates/plate-playground-template/src/components/editor/use-chat.tsx @@ -217,7 +217,7 @@ export function SettingsDialog() { Enter your{' '} -

+

Not stored anywhere. Used only for current session requests.

diff --git a/templates/plate-playground-template/src/components/plate-ui/media-placeholder-element.tsx b/templates/plate-playground-template/src/components/plate-ui/media-placeholder-element.tsx index 8f2eb722d9..76713b8be0 100644 --- a/templates/plate-playground-template/src/components/plate-ui/media-placeholder-element.tsx +++ b/templates/plate-playground-template/src/components/plate-ui/media-placeholder-element.tsx @@ -230,6 +230,7 @@ export function ImageProgress({ return (
+ {/* eslint-disable-next-line @next/next/no-img-element */}
-
+
diff --git a/templates/plate-playground-template/src/lib/uploadthing/handle-error.ts b/templates/plate-playground-template/src/lib/uploadthing/handle-error.ts deleted file mode 100644 index f04db185ad..0000000000 --- a/templates/plate-playground-template/src/lib/uploadthing/handle-error.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { isRedirectError } from 'next/dist/client/components/redirect'; -import { toast } from 'sonner'; -import { z } from 'zod'; - -export function getErrorMessage(err: unknown) { - const unknownError = 'Something went wrong, please try again later.'; - - if (err instanceof z.ZodError) { - const errors = err.issues.map((issue) => { - return issue.message; - }); - - return errors.join('\n'); - } else if (err instanceof Error) { - return err.message; - } else if (isRedirectError(err)) { - throw err; - } else { - return unknownError; - } -} - -export function showErrorToast(err: unknown) { - const errorMessage = getErrorMessage(err); - - return toast.error(errorMessage); -} diff --git a/templates/plate-playground-template/src/lib/uploadthing/index.ts b/templates/plate-playground-template/src/lib/uploadthing/index.ts index 55dcc822a3..d83b377c7d 100644 --- a/templates/plate-playground-template/src/lib/uploadthing/index.ts +++ b/templates/plate-playground-template/src/lib/uploadthing/index.ts @@ -1,5 +1 @@ -export * from './handle-error'; - export * from './uploadthing'; - -export * from './use-upload-file'; diff --git a/templates/plate-playground-template/src/lib/uploadthing/uploadthing.ts b/templates/plate-playground-template/src/lib/uploadthing/uploadthing.ts index 90f48864d6..c5dbe904ce 100644 --- a/templates/plate-playground-template/src/lib/uploadthing/uploadthing.ts +++ b/templates/plate-playground-template/src/lib/uploadthing/uploadthing.ts @@ -1,6 +1,102 @@ +import * as React from 'react'; + import type { OurFileRouter } from '@/app/api/uploadthing/core'; +import type { + ClientUploadedFileData, + UploadFilesOptions, +} from 'uploadthing/types'; import { generateReactHelpers } from '@uploadthing/react'; +import { isRedirectError } from 'next/dist/client/components/redirect'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +export interface UploadedFile extends ClientUploadedFileData {} + +interface UseUploadFileProps + extends Pick< + UploadFilesOptions, + 'headers' | 'onUploadBegin' | 'onUploadProgress' | 'skipPolling' + > { + onUploadComplete?: (file: UploadedFile) => void; + onUploadError?: (error: unknown) => void; +} + +export function useUploadFile( + endpoint: keyof OurFileRouter, + { onUploadComplete, onUploadError, ...props }: UseUploadFileProps = {} +) { + const [uploadedFile, setUploadedFile] = React.useState(); + const [uploadingFile, setUploadingFile] = React.useState(); + const [progress, setProgress] = React.useState(0); + const [isUploading, setIsUploading] = React.useState(false); + + async function uploadThing(file: File) { + setIsUploading(true); + setUploadingFile(file); + + try { + const res = await uploadFiles(endpoint, { + ...props, + files: [file], + onUploadProgress: ({ progress }) => { + setProgress(Math.min(progress, 100)); + }, + }); + + setUploadedFile(res[0]); + + onUploadComplete?.(res[0]); + return uploadedFile; + } catch (error) { + const errorMessage = getErrorMessage(error); + + const message = + errorMessage.length > 0 + ? errorMessage + : 'Something went wrong, please try again later.'; + + toast.error(message); + onUploadError?.(error); + } finally { + setProgress(0); + setIsUploading(false); + setUploadingFile(undefined); + } + } + + return { + isUploading, + progress, + uploadFile: uploadThing, + uploadedFile, + uploadingFile, + }; +} export const { uploadFiles, useUploadThing } = generateReactHelpers(); + +export function getErrorMessage(err: unknown) { + const unknownError = 'Something went wrong, please try again later.'; + + if (err instanceof z.ZodError) { + const errors = err.issues.map((issue) => { + return issue.message; + }); + + return errors.join('\n'); + } else if (err instanceof Error) { + return err.message; + } else if (isRedirectError(err)) { + throw err; + } else { + return unknownError; + } +} + +export function showErrorToast(err: unknown) { + const errorMessage = getErrorMessage(err); + + return toast.error(errorMessage); +} diff --git a/templates/plate-playground-template/src/lib/uploadthing/use-upload-file.ts b/templates/plate-playground-template/src/lib/uploadthing/use-upload-file.ts deleted file mode 100644 index d4d62b6aef..0000000000 --- a/templates/plate-playground-template/src/lib/uploadthing/use-upload-file.ts +++ /dev/null @@ -1,76 +0,0 @@ -import * as React from 'react'; - -import type { OurFileRouter } from '@/app/api/uploadthing/core'; -import type { - ClientUploadedFileData, - UploadFilesOptions, -} from 'uploadthing/types'; - -import { toast } from 'sonner'; - -import { getErrorMessage } from './handle-error'; -import { uploadFiles } from './uploadthing'; - -export interface UploadedFile extends ClientUploadedFileData {} - -interface UseUploadFileProps - extends Pick< - UploadFilesOptions, - 'headers' | 'onUploadBegin' | 'onUploadProgress' | 'skipPolling' - > { - onUploadComplete?: (file: UploadedFile) => void; - onUploadError?: (error: unknown) => void; -} - -export function useUploadFile( - endpoint: keyof OurFileRouter, - { onUploadComplete, onUploadError, ...props }: UseUploadFileProps = {} -) { - const [uploadedFile, setUploadedFile] = React.useState(); - const [uploadingFile, setUploadingFile] = React.useState(); - const [progress, setProgress] = React.useState(0); - const [isUploading, setIsUploading] = React.useState(false); - - async function uploadThing(file: File) { - setIsUploading(true); - setUploadingFile(file); - - try { - const res = await uploadFiles(endpoint, { - ...props, - files: [file], - onUploadProgress: ({ progress }) => { - setProgress(Math.min(progress, 100)); - }, - }); - - setUploadedFile(res[0]); - - onUploadComplete?.(res[0]); - - return uploadedFile; - } catch (error) { - const errorMessage = getErrorMessage(error); - - const message = - errorMessage.length > 0 - ? errorMessage - : 'Something went wrong, please try again later.'; - - toast.error(message); - onUploadError?.(error); - } finally { - setProgress(0); - setIsUploading(false); - setUploadingFile(undefined); - } - } - - return { - isUploading, - progress, - uploadFile: uploadThing, - uploadedFile, - uploadingFile, - }; -}