Skip to content

Commit 44aeeab

Browse files
authored
Merge pull request #3782 from udecode/docs/upload
Docs/upload
2 parents a12690a + 0bc12e0 commit 44aeeab

31 files changed

+556
-182
lines changed

.changeset/thin-dragons-deny.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@udecode/plate-media': patch
3+
---
4+
5+
Fix error message.

apps/www/content/docs/components/changelog.mdx

+2-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ Use the [CLI](https://platejs.org/docs/components/cli) to install the latest ver
1313

1414
### November 14 #16.7
1515

16-
Add `ToolbarSplitButton` in `toolbar.tsx`.
16+
Add `ToolbarSplitButton`, `ToolbarSplitButtonPrimary`, `ToolbarSplitButtonSecondary` in `toolbar.tsx`.
17+
Refactor `media-toolbar-button.tsx` to use the new split button.
1718

1819
### November 13 #16.6
1920

apps/www/content/docs/media-placeholder.mdx

+109-5
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
title: Media Placeholder
33
description: Media placeholders to be used as clickable placeholders for various media types (image, video, audio, file).
44
docs:
5-
- route: https://pro.platejs.org/docs/components/media-placeholder-element
5+
- route: components/media-placeholder-element
66
title: Media Placeholder Element
7+
- route: components/media-upload-toast
8+
title: Media Upload Toast
79
---
810

911
<PackageInfo>
@@ -22,6 +24,7 @@ npm install @udecode/plate-media
2224
```
2325

2426
## Usage
27+
How to configuration the backend see [Upload](/docs/upload).
2528

2629
```tsx
2730
import {
@@ -37,7 +40,14 @@ import {
3740
```tsx
3841
const plugins = [
3942
// ...otherPlugins,
40-
PlaceholderPlugin,
43+
PlaceholderPlugin.configure({
44+
options: {
45+
disableEmptyPlaceholder: true,
46+
},
47+
render: {
48+
afterEditable: () => <MediaUploadToast />,
49+
},
50+
}),
4151
];
4252
```
4353

@@ -48,17 +58,111 @@ const components = {
4858
};
4959
```
5060

51-
- [MediaPlaceholderElement](https://pro.platejs.org/docs/components/media-placeholder-element) (Plus)
61+
62+
## UploadOptions
63+
64+
### uploadConfig
65+
66+
Configuration for different file types:
67+
68+
- You can use this option to configure upload limits for each file type, including:
69+
70+
- Maximum file count (e.g. `maxFileCount: 1`)
71+
- Maximum file size (e.g. `maxFileSize: '8MB'`)
72+
- Minimum file count (e.g. `minFileCount: 1`)
73+
- mediaType: Used for passing to the media-placeholder-elements file to distinguish between different file types and their progress bar styles.
74+
75+
default configuration:
76+
77+
```tsx
78+
uploadConfig: {
79+
audio: {
80+
maxFileCount: 1,
81+
maxFileSize: '8MB',
82+
mediaType: AudioPlugin.key,
83+
minFileCount: 1,
84+
},
85+
blob: {
86+
maxFileCount: 1,
87+
maxFileSize: '8MB',
88+
mediaType: FilePlugin.key,
89+
minFileCount: 1,
90+
},
91+
image: {
92+
maxFileCount: 3,
93+
maxFileSize: '4MB',
94+
mediaType: ImagePlugin.key,
95+
minFileCount: 1,
96+
},
97+
pdf: {
98+
maxFileCount: 1,
99+
maxFileSize: '4MB',
100+
mediaType: FilePlugin.key,
101+
minFileCount: 1,
102+
},
103+
text: {
104+
maxFileCount: 1,
105+
maxFileSize: '64KB',
106+
mediaType: FilePlugin.key,
107+
minFileCount: 1,
108+
},
109+
video: {
110+
maxFileCount: 1,
111+
maxFileSize: '16MB',
112+
mediaType: VideoPlugin.key,
113+
minFileCount: 1,
114+
},
115+
},
116+
```
117+
118+
here is all allowed file types (keys for `uploadConfig`):
119+
120+
```tsx
121+
export const ALLOWED_FILE_TYPES = [
122+
'image',
123+
'video',
124+
'audio',
125+
'pdf',
126+
'text',
127+
'blob',
128+
] as const;
129+
```
130+
131+
### disableEmptyPlaceholder
132+
133+
`boolean` (default: `false`)
134+
135+
Disable empty placeholder when no file is selected.
136+
137+
### disableFileDrop
138+
139+
`boolean` (default: `false`)
140+
141+
Whether we can undo to the placeholder after the file upload is complete.
142+
143+
### maxFileCount
144+
145+
`number` (default: `5`)
146+
147+
Maximum number of files that can be uploaded at once.
148+
149+
### multiple
150+
151+
`boolean` (default: `true`)
152+
153+
Whether multiple files can be uploaded in one time.
52154

53155
## Examples
54156

157+
<ComponentPreview name="playground-demo" id="mediaPlaceholder" />
158+
55159
### Plate UI
56160

57-
Work in progress.
161+
Refer to the preview above.
58162

59163
### Plate Plus
60164

61-
<ComponentPreviewPro name="media-placeholder-pro" />
165+
<ComponentPreviewPro name="upload-pro" />
62166

63167
## Plugins
64168

apps/www/content/docs/upload.mdx

+9-14
Original file line numberDiff line numberDiff line change
@@ -6,32 +6,27 @@ docs:
66
title: Upload
77
---
88

9+
### UploadThing Integration
910

10-
<ComponentPreview name="playground-demo" id="upload" />
11+
Make sure you have install the [media-placeholder-element](/docs/components/media-placeholder-element) component and all the dependencies.
1112

12-
{/* ### UploadThing Integration
13+
Set `UPLOADTHING_TOKEN` in your .env file [get one here](https://uploadthing.com/dashboard).
1314

14-
This component uses UploadThing for file uploads. UploadThing provides a simple and efficient way to handle file uploads in your application.
1515

16-
To use UploadThing:
16+
### Using your own backend
1717

18-
1. Set up an UploadThing account and configure your upload endpoints.
19-
2. Install the UploadThing client in your project:
18+
Remove this two folder `lib/uploadthing` and `/api/uploadthing`.
2019

21-
```bash
22-
npm install uploadthing
23-
```
24-
25-
3. Configure the UploadThing client in your application.
26-
27-
For more details on setting up UploadThing, refer to their [documentation](https://docs.uploadthing.com/). */}
20+
Then impelement a similar hooks like `useUploadFile` using your own backend.
2821

2922
## Examples
3023

24+
<ComponentPreview name="playground-demo" id="mediaPlaceholder" />
25+
3126
### Plate UI
3227

3328
Work in progress.
3429

3530
### Plate Plus
3631

37-
<ComponentPreviewPro name="upload-pro" />
32+
<ComponentPreviewPro name="upload-pro" />

apps/www/public/r/index.json

+61
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,67 @@
3030
"registryDependencies": [],
3131
"type": "registry:ui"
3232
},
33+
{
34+
"dependencies": [
35+
"@udecode/plate-media",
36+
"use-file-picker",
37+
"@uploadthing/[email protected]",
38+
39+
"zod",
40+
"sonner"
41+
],
42+
"doc": {
43+
"description": "A placeholder for media files.",
44+
"docs": [
45+
{
46+
"route": "/docs/media-placeholder",
47+
"title": "Media Placeholder"
48+
}
49+
],
50+
"examples": [
51+
"media-demo",
52+
"media-toolbar-pro"
53+
]
54+
},
55+
"files": [
56+
{
57+
"path": "plate-ui/media-placeholder-element.tsx",
58+
"type": "registry:ui"
59+
},
60+
{
61+
"path": "lib/uploadthing/uploadthing.ts",
62+
"type": "registry:ui"
63+
}
64+
],
65+
"name": "media-placeholder-element",
66+
"registryDependencies": [],
67+
"type": "registry:ui"
68+
},
69+
{
70+
"dependencies": [],
71+
"doc": {
72+
"description": "A toast for media uploads.",
73+
"docs": [
74+
{
75+
"route": "/docs/media-placeholder",
76+
"title": "Media Placeholder"
77+
}
78+
],
79+
"examples": [
80+
"media-demo",
81+
"upload-pro"
82+
]
83+
},
84+
"files": [
85+
{
86+
"path": "plate-ui/media-upload-toast.tsx",
87+
"type": "registry:ui"
88+
}
89+
],
90+
"name": "media-upload-toast",
91+
"registryDependencies": [],
92+
"type": "registry:ui"
93+
},
3394
{
3495
"dependencies": [],
3596
"doc": {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"dependencies": [
3+
"@udecode/plate-media",
4+
"use-file-picker",
5+
"@uploadthing/[email protected]",
6+
7+
"zod",
8+
"sonner"
9+
],
10+
"doc": {
11+
"description": "A placeholder for media files.",
12+
"docs": [
13+
{
14+
"route": "/docs/media-placeholder",
15+
"title": "Media Placeholder"
16+
}
17+
],
18+
"examples": [
19+
"media-demo",
20+
"media-toolbar-pro"
21+
]
22+
},
23+
"files": [
24+
{
25+
"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: <AudioLines />,\n },\n [FilePlugin.key]: {\n accept: ['*'],\n content: 'Add a file',\n icon: <FileUp />,\n },\n [ImagePlugin.key]: {\n accept: ['image/*'],\n content: 'Add an image',\n icon: <ImageIcon />,\n },\n [VideoPlugin.key]: {\n accept: ['video/*'],\n content: 'Add a video',\n icon: <Film />,\n },\n};\n\nexport const MediaPlaceholderElement = withHOC(\n PlaceholderProvider,\n withRef<typeof PlateElement>(\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<HTMLImageElement>(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 <PlateElement\n ref={ref}\n className={cn('relative my-1', className)}\n editor={editor}\n {...props}\n >\n {(!loading || !isImage) && (\n <div\n className={cn(\n 'flex cursor-pointer select-none items-center rounded-sm bg-muted p-3 pr-9 hover:bg-primary/10'\n )}\n onClick={() => !loading && openFilePicker()}\n contentEditable={false}\n >\n <div className=\"relative mr-3 flex text-muted-foreground/80 [&_svg]:size-6\">\n {currentContent.icon}\n </div>\n <div className=\"whitespace-nowrap text-sm text-muted-foreground\">\n <div>\n {loading ? uploadingFile?.name : currentContent.content}\n </div>\n\n {loading && !isImage && (\n <div className=\"mt-1 flex items-center gap-1.5\">\n <div>{formatBytes(uploadingFile?.size ?? 0)}</div>\n <div>–</div>\n <div className=\"flex items-center\">\n <Spinner className=\"mr-1 size-3.5\" />\n {progress ?? 0}%\n </div>\n </div>\n )}\n </div>\n </div>\n )}\n\n {isImage && loading && (\n <ImageProgress\n file={uploadingFile}\n imageRef={imageRef}\n progress={progress}\n />\n )}\n\n {children}\n </PlateElement>\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<HTMLImageElement>;\n progress?: number;\n}) {\n const [objectUrl, setObjectUrl] = useState<string | null>(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 <div className={cn('relative', className)} contentEditable={false}>\n <img\n ref={imageRef}\n className=\"h-auto w-full rounded-sm object-cover\"\n alt={file.name}\n src={objectUrl}\n />\n {progress < 100 && (\n <div className=\"absolute bottom-1 right-1 flex items-center space-x-2 rounded-full bg-black/50 px-1 py-0.5\">\n <Spinner />\n <span className=\"text-xs font-medium text-white\">\n {Math.round(progress)}%\n </span>\n </div>\n )}\n </div>\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",
26+
"path": "plate-ui/media-placeholder-element.tsx",
27+
"target": "components/plate-ui/media-placeholder-element.tsx",
28+
"type": "registry:ui"
29+
},
30+
{
31+
"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<UploadedFile>();\n const [uploadingFile, setUploadingFile] = React.useState<File>();\n const [progress, setProgress] = React.useState<number>(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",
32+
"path": "lib/uploadthing/uploadthing.ts",
33+
"target": "components/plate-ui/uploadthing.ts",
34+
"type": "registry:ui"
35+
}
36+
],
37+
"name": "media-placeholder-element",
38+
"registryDependencies": [],
39+
"type": "registry:ui"
40+
}

0 commit comments

Comments
 (0)