Skip to content

Commit

Permalink
Merge pull request #3782 from udecode/docs/upload
Browse files Browse the repository at this point in the history
Docs/upload
  • Loading branch information
felixfeng33 authored Nov 18, 2024
2 parents a12690a + 0bc12e0 commit 44aeeab
Show file tree
Hide file tree
Showing 31 changed files with 556 additions and 182 deletions.
5 changes: 5 additions & 0 deletions .changeset/thin-dragons-deny.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@udecode/plate-media': patch
---

Fix error message.
3 changes: 2 additions & 1 deletion apps/www/content/docs/components/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
114 changes: 109 additions & 5 deletions apps/www/content/docs/media-placeholder.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
---

<PackageInfo>
Expand All @@ -22,6 +24,7 @@ npm install @udecode/plate-media
```

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

```tsx
import {
Expand All @@ -37,7 +40,14 @@ import {
```tsx
const plugins = [
// ...otherPlugins,
PlaceholderPlugin,
PlaceholderPlugin.configure({
options: {
disableEmptyPlaceholder: true,
},
render: {
afterEditable: () => <MediaUploadToast />,
},
}),
];
```

Expand All @@ -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

<ComponentPreview name="playground-demo" id="mediaPlaceholder" />

### Plate UI

Work in progress.
Refer to the preview above.

### Plate Plus

<ComponentPreviewPro name="media-placeholder-pro" />
<ComponentPreviewPro name="upload-pro" />

## Plugins

Expand Down
23 changes: 9 additions & 14 deletions apps/www/content/docs/upload.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,27 @@ docs:
title: Upload
---

### UploadThing Integration

<ComponentPreview name="playground-demo" id="upload" />
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

<ComponentPreview name="playground-demo" id="mediaPlaceholder" />

### Plate UI

Work in progress.

### Plate Plus

<ComponentPreviewPro name="upload-pro" />
<ComponentPreviewPro name="upload-pro" />
61 changes: 61 additions & 0 deletions apps/www/public/r/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,67 @@
"registryDependencies": [],
"type": "registry:ui"
},
{
"dependencies": [
"@udecode/plate-media",
"use-file-picker",
"@uploadthing/[email protected]",
"[email protected]",
"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": {
Expand Down
40 changes: 40 additions & 0 deletions apps/www/public/r/styles/default/media-placeholder-element.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"dependencies": [
"@udecode/plate-media",
"use-file-picker",
"@uploadthing/[email protected]",
"[email protected]",
"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: <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",
"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<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",
"path": "lib/uploadthing/uploadthing.ts",
"target": "components/plate-ui/uploadthing.ts",
"type": "registry:ui"
}
],
"name": "media-placeholder-element",
"registryDependencies": [],
"type": "registry:ui"
}
Loading

0 comments on commit 44aeeab

Please sign in to comment.