diff --git a/.changeset/witty-eels-agree.md b/.changeset/witty-eels-agree.md new file mode 100644 index 0000000000..d330a568f7 --- /dev/null +++ b/.changeset/witty-eels-agree.md @@ -0,0 +1,5 @@ +--- +'@udecode/plate-media': patch +--- + +Fix editor crash when inserting media into an empty paragraph. diff --git a/apps/www/content/docs/media-placeholder.mdx b/apps/www/content/docs/media-placeholder.mdx index 6ec9d3585b..c46767ce5c 100644 --- a/apps/www/content/docs/media-placeholder.mdx +++ b/apps/www/content/docs/media-placeholder.mdx @@ -25,7 +25,6 @@ npm install @udecode/plate-media ``` ## Usage -How to configuration the backend see [Upload](/docs/upload). ```tsx import { @@ -59,11 +58,78 @@ const components = { }; ``` +### UploadThing Integration -## UploadOptions +The UploadThing integration provides an easy way to handle file uploads in your editor. Follow these steps to set it up: -### uploadConfig +1. Install the required dependencies: + ```bash + npm install @uploadthing/react uploadthing + ``` + +2. Install the [media-placeholder-element](/docs/components/media-placeholder-element) component. + +3. Set up the UploadThing API route by copying the [example implementation](https://github.com/udecode/plate/blob/main/templates/plate-playground-template/src/app/api/uploadthing/core.ts). + +4. Get your UploadThing API key from the [dashboard](https://uploadthing.com/dashboard) and add it to your `.env` file: + ```bash + UPLOADTHING_SECRET=your_secret_key + ``` + +### Using your own backend + +To implement your own backend for file uploads: + +1. Remove the UploadThing implementation: + + ```bash + lib/uploadthing/ + api/uploadthing/ + ``` + +2. Create your own upload hook: + + ```ts + function useUploadFile() { + // Your implementation here + return { + isUploading: boolean, + progress: number, + uploadFile: (file: File) => Promise, + uploadedFile: UploadedFile | undefined, + uploadingFile: File | undefined, + }; + } + ``` + +3. The hook should match the interface expected by the media placeholder component: + ```ts + interface UploadedFile { + key: string; + url: string; + name: string; + size: number; + type: string; + } + ``` + +### Plate UI + +Refer to the preview above. + +### Plate Plus + + + +## Plugins + +### PlaceholderPlugin + +Media placeholder element plugin. + + + Configuration for different file types: - You can use this option to configure upload limits for each file type, including: @@ -129,45 +195,26 @@ Configuration for different file types: ] as const; ``` -### disableEmptyPlaceholder - -`boolean` (default: `false`) + + Disable empty placeholder when no file is selected. -### disableFileDrop - -`boolean` (default: `false`) +- **Default:** `false` + + Whether we can undo to the placeholder after the file upload is complete. -### maxFileCount - -`number` (default: `5`) +- **Default:** `false` + + 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 - -Refer to the preview above. - -### Plate Plus - - - -## Plugins - -### PlaceholderPlugin - -Media placeholder element plugin. +- **Default:** `5` + + ## Transforms diff --git a/apps/www/package.json b/apps/www/package.json index 837f4d8ab2..649de99b9e 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -131,6 +131,7 @@ "@udecode/slate-react": "workspace:^", "@udecode/slate-utils": "workspace:^", "@udecode/utils": "workspace:^", + "@uploadthing/react": "7.1.0", "@vercel/og": "^0.6.2", "ai": "^3.4.10", "class-variance-authority": "^0.7.0", @@ -172,6 +173,7 @@ "ts-morph": "^22.0.0", "unist-builder": "4.0.0", "unist-util-visit": "^5.0.0", + "uploadthing": "7.2.0", "use-file-picker": "2.1.2", "vaul": "0.9.0" }, diff --git a/apps/www/public/r/styles/default/api-uploadthing.json b/apps/www/public/r/styles/default/api-uploadthing.json new file mode 100644 index 0000000000..7e021a2c51 --- /dev/null +++ b/apps/www/public/r/styles/default/api-uploadthing.json @@ -0,0 +1,19 @@ +{ + "dependencies": [ + "uploadthing@7.2.0" + ], + "files": [ + { + "content": "import type { FileRouter } from 'uploadthing/next';\n\nimport { createRouteHandler, createUploadthing } from 'uploadthing/next';\n\nconst f = createUploadthing();\n\n// FileRouter for your app, can contain multiple FileRoutes\nconst ourFileRouter = {\n // Define as many FileRoutes as you like, each with a unique routeSlug\n imageUploader: f(['image', 'text', 'blob', 'pdf', 'video', 'audio'])\n // Set permissions and file types for this FileRoute\n // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/require-await\n .middleware(async ({ req }) => {\n // This code runs on your server before upload\n\n // Whatever is returned here is accessible in onUploadComplete as `metadata`\n return {};\n })\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n .onUploadComplete(({ file, metadata }) => {\n // This code RUNS ON YOUR SERVER after upload\n\n // !!! Whatever is returned here is sent to the clientside `onClientUploadComplete` callback\n return { file };\n }),\n} satisfies FileRouter;\n\nexport type OurFileRouter = typeof ourFileRouter;\n\n// Export routes for Next App Router\nexport const { GET, POST } = createRouteHandler({\n router: ourFileRouter,\n\n // Apply an (optional) custom config:\n // config: { ... },\n});\n", + "path": "components/api/uploadthing/route.ts", + "target": "app/api/uploadthing/route.ts", + "type": "registry:page" + } + ], + "name": "api-uploadthing", + "registryDependencies": [ + "media-placeholder-element", + "uploadthing" + ], + "type": "registry:component" +} \ No newline at end of file diff --git a/apps/www/public/r/styles/default/media-placeholder-element.json b/apps/www/public/r/styles/default/media-placeholder-element.json index 89118cd1d4..3d32431387 100644 --- a/apps/www/public/r/styles/default/media-placeholder-element.json +++ b/apps/www/public/r/styles/default/media-placeholder-element.json @@ -23,13 +23,13 @@ }, "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", + "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';\n\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('imageUploader');\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, 50));\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", + "content": "import * as React from 'react';\n\nimport type { OurFileRouter } from '@/components/api/uploadthing/route';\nimport type {\n ClientUploadedFileData,\n UploadFilesOptions,\n} from 'uploadthing/types';\n\nimport { generateReactHelpers } from '@uploadthing/react';\nimport { isRedirectError } from 'next/dist/client/components/redirect';\nimport { toast } from 'sonner';\nimport { z } from 'zod';\n\nexport interface UploadedFile extends ClientUploadedFileData {}\n\ninterface UseUploadFileProps\n extends Pick<\n UploadFilesOptions,\n 'headers' | 'onUploadBegin' | 'onUploadProgress' | 'skipPolling'\n > {\n onUploadComplete?: (file: UploadedFile) => void;\n onUploadError?: (error: unknown) => void;\n}\n\nexport function useUploadFile(\n endpoint: keyof OurFileRouter,\n { onUploadComplete, onUploadError, ...props }: UseUploadFileProps = {}\n) {\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 const res = await uploadFiles(endpoint, {\n ...props,\n files: [file],\n onUploadProgress: ({ progress }) => {\n setProgress(Math.min(progress, 100));\n },\n });\n\n setUploadedFile(res[0]);\n\n onUploadComplete?.(res[0]);\n\n return uploadedFile;\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\n onUploadError?.(error);\n\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, 50));\n progress += 2;\n setProgress(Math.min(progress, 100));\n }\n };\n\n await simulateProgress();\n\n setUploadedFile(mockUploadedFile);\n\n return mockUploadedFile;\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 const { uploadFiles, useUploadThing } =\n generateReactHelpers();\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.ts", "target": "components/plate-ui/uploadthing.ts", "type": "registry:ui" diff --git a/apps/www/public/r/styles/default/uploadthing.json b/apps/www/public/r/styles/default/uploadthing.json new file mode 100644 index 0000000000..5685781e1b --- /dev/null +++ b/apps/www/public/r/styles/default/uploadthing.json @@ -0,0 +1,16 @@ +{ + "dependencies": [ + "uploadthing@7.2.0", + "sonner" + ], + "files": [ + { + "content": "import * as React from 'react';\n\nimport type { OurFileRouter } from '@/components/api/uploadthing/route';\nimport type {\n ClientUploadedFileData,\n UploadFilesOptions,\n} from 'uploadthing/types';\n\nimport { generateReactHelpers } from '@uploadthing/react';\nimport { isRedirectError } from 'next/dist/client/components/redirect';\nimport { toast } from 'sonner';\nimport { z } from 'zod';\n\nexport interface UploadedFile extends ClientUploadedFileData {}\n\ninterface UseUploadFileProps\n extends Pick<\n UploadFilesOptions,\n 'headers' | 'onUploadBegin' | 'onUploadProgress' | 'skipPolling'\n > {\n onUploadComplete?: (file: UploadedFile) => void;\n onUploadError?: (error: unknown) => void;\n}\n\nexport function useUploadFile(\n endpoint: keyof OurFileRouter,\n { onUploadComplete, onUploadError, ...props }: UseUploadFileProps = {}\n) {\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 const res = await uploadFiles(endpoint, {\n ...props,\n files: [file],\n onUploadProgress: ({ progress }) => {\n setProgress(Math.min(progress, 100));\n },\n });\n\n setUploadedFile(res[0]);\n\n onUploadComplete?.(res[0]);\n\n return uploadedFile;\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\n onUploadError?.(error);\n\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, 50));\n progress += 2;\n setProgress(Math.min(progress, 100));\n }\n };\n\n await simulateProgress();\n\n setUploadedFile(mockUploadedFile);\n\n return mockUploadedFile;\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 const { uploadFiles, useUploadThing } =\n generateReactHelpers();\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.ts", + "target": "lib/uploadthing.ts", + "type": "registry:lib" + } + ], + "name": "uploadthing", + "type": "registry:lib" +} \ No newline at end of file diff --git a/apps/www/src/__registry__/index.tsx b/apps/www/src/__registry__/index.tsx index e3b1cfdd6b..578613da20 100644 --- a/apps/www/src/__registry__/index.tsx +++ b/apps/www/src/__registry__/index.tsx @@ -1505,6 +1505,18 @@ export const Index: Record = { subcategory: "", chunks: [] }, + "api-uploadthing": { + name: "api-uploadthing", + description: "", + type: "registry:component", + registryDependencies: ["media-placeholder-element","uploadthing"], + files: ["registry/default/components/api/uploadthing/route.ts"], + component: React.lazy(() => import("@/registry/default/components/api/uploadthing/route.ts")), + source: "", + category: "", + subcategory: "", + chunks: [] + }, "transforms": { name: "transforms", description: "", @@ -2369,6 +2381,18 @@ export const Index: Record = { subcategory: "", chunks: [] }, + "uploadthing": { + name: "uploadthing", + description: "", + type: "registry:lib", + registryDependencies: undefined, + files: ["registry/default/lib/uploadthing.ts"], + component: React.lazy(() => import("@/registry/default/lib/uploadthing.ts")), + source: "", + category: "", + subcategory: "", + chunks: [] + }, "use-debounce": { name: "use-debounce", description: "", diff --git a/templates/plate-playground-template/src/app/api/uploadthing/core.ts b/apps/www/src/registry/default/components/api/uploadthing/route.ts similarity index 65% rename from templates/plate-playground-template/src/app/api/uploadthing/core.ts rename to apps/www/src/registry/default/components/api/uploadthing/route.ts index 3233c7cb10..d19e1ed2f3 100644 --- a/templates/plate-playground-template/src/app/api/uploadthing/core.ts +++ b/apps/www/src/registry/default/components/api/uploadthing/route.ts @@ -1,20 +1,22 @@ import type { FileRouter } from 'uploadthing/next'; -import { createUploadthing } from 'uploadthing/next'; +import { createRouteHandler, createUploadthing } from 'uploadthing/next'; const f = createUploadthing(); // FileRouter for your app, can contain multiple FileRoutes -export const ourFileRouter = { +const ourFileRouter = { // Define as many FileRoutes as you like, each with a unique routeSlug imageUploader: f(['image', 'text', 'blob', 'pdf', 'video', 'audio']) // Set permissions and file types for this FileRoute + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/require-await .middleware(async ({ req }) => { // This code runs on your server before upload // Whatever is returned here is accessible in onUploadComplete as `metadata` return {}; }) + // eslint-disable-next-line @typescript-eslint/no-unused-vars .onUploadComplete(({ file, metadata }) => { // This code RUNS ON YOUR SERVER after upload @@ -24,3 +26,11 @@ export const ourFileRouter = { } satisfies FileRouter; export type OurFileRouter = typeof ourFileRouter; + +// Export routes for Next App Router +export const { GET, POST } = createRouteHandler({ + router: ourFileRouter, + + // Apply an (optional) custom config: + // config: { ... }, +}); diff --git a/apps/www/src/registry/default/lib/uploadthing.ts b/apps/www/src/registry/default/lib/uploadthing.ts index 261d01bd92..2240fd4b49 100644 --- a/apps/www/src/registry/default/lib/uploadthing.ts +++ b/apps/www/src/registry/default/lib/uploadthing.ts @@ -1,19 +1,31 @@ import * as React from 'react'; +import type { OurFileRouter } from '@/registry/default/components/api/uploadthing/route'; +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 { - key: string; - appUrl: string; - name: string; - size: number; - type: string; - url: string; +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() { +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); @@ -24,6 +36,31 @@ export function useUploadFile() { 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); + // Mock upload for unauthenticated users // toast.info('User not logged in. Mocking upload process.'); const mockUploadedFile = { @@ -51,15 +88,6 @@ export function useUploadFile() { setUploadedFile(mockUploadedFile); return mockUploadedFile; - } catch (error) { - const errorMessage = getErrorMessage(error); - - const message = - errorMessage.length > 0 - ? errorMessage - : 'Something went wrong, please try again later.'; - - toast.error(message); } finally { setProgress(0); setIsUploading(false); @@ -76,6 +104,9 @@ export function useUploadFile() { }; } +export const { uploadFiles, useUploadThing } = + generateReactHelpers(); + export function getErrorMessage(err: unknown) { const unknownError = 'Something went wrong, please try again later.'; diff --git a/apps/www/src/registry/default/plate-ui/input.tsx b/apps/www/src/registry/default/plate-ui/input.tsx index c144057b80..ee0a423dca 100644 --- a/apps/www/src/registry/default/plate-ui/input.tsx +++ b/apps/www/src/registry/default/plate-ui/input.tsx @@ -1,7 +1,7 @@ -import { withVariants } from '@udecode/cn'; -import { type VariantProps, cva } from 'class-variance-authority'; +import React from 'react'; -import { cn } from '@/registry/default/lib/utils'; +import { cn, withVariants } from '@udecode/cn'; +import { type VariantProps, cva } from 'class-variance-authority'; export const inputVariants = cva( 'flex w-full rounded-md bg-transparent text-sm file:border-0 file:bg-background file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50', diff --git a/apps/www/src/registry/default/plate-ui/media-placeholder-element.tsx b/apps/www/src/registry/default/plate-ui/media-placeholder-element.tsx index 5a3c80c232..812c9790f3 100644 --- a/apps/www/src/registry/default/plate-ui/media-placeholder-element.tsx +++ b/apps/www/src/registry/default/plate-ui/media-placeholder-element.tsx @@ -29,7 +29,8 @@ import { import { AudioLines, FileUp, Film, ImageIcon } from 'lucide-react'; import { useFilePicker } from 'use-file-picker'; -import { useUploadFile } from '../lib/uploadthing'; +import { useUploadFile } from '@/registry/default/lib/uploadthing'; + import { PlateElement } from './plate-element'; import { Spinner } from './spinner'; @@ -72,7 +73,7 @@ export const MediaPlaceholderElement = withHOC( const { api } = useEditorPlugin(PlaceholderPlugin); const { isUploading, progress, uploadFile, uploadedFile, uploadingFile } = - useUploadFile(); + useUploadFile('imageUploader'); const loading = isUploading && uploadingFile; diff --git a/apps/www/src/registry/registry-components.ts b/apps/www/src/registry/registry-components.ts index a79f70beaf..4a8495c47a 100644 --- a/apps/www/src/registry/registry-components.ts +++ b/apps/www/src/registry/registry-components.ts @@ -337,6 +337,19 @@ export const components: Registry = [ registryDependencies: ['use-chat'], type: 'registry:component', }, + { + dependencies: ['uploadthing@7.2.0'], + files: [ + { + path: 'components/api/uploadthing/route.ts', + target: 'app/api/uploadthing/route.ts', + type: 'registry:page', + }, + ], + name: 'api-uploadthing', + registryDependencies: ['media-placeholder-element', 'uploadthing'], + type: 'registry:component', + }, { dependencies: [ '@udecode/plate-callout', diff --git a/apps/www/src/registry/registry-lib.ts b/apps/www/src/registry/registry-lib.ts index 3eac18913d..216e62c2c0 100644 --- a/apps/www/src/registry/registry-lib.ts +++ b/apps/www/src/registry/registry-lib.ts @@ -12,4 +12,15 @@ export const lib: Registry = [ name: 'utils', type: 'registry:lib', }, + { + dependencies: ['uploadthing@7.2.0', 'sonner'], + files: [ + { + path: 'lib/uploadthing.ts', + type: 'registry:lib', + }, + ], + name: 'uploadthing', + type: 'registry:lib', + }, ]; diff --git a/packages/html/src/__tests__/create-plate-ui-editor.ts b/packages/html/src/__tests__/create-plate-ui-editor.ts index cb6f583534..ad7fc6d37d 100644 --- a/packages/html/src/__tests__/create-plate-ui-editor.ts +++ b/packages/html/src/__tests__/create-plate-ui-editor.ts @@ -44,7 +44,6 @@ import { FilePlugin, ImagePlugin, MediaEmbedPlugin, - PlaceholderPlugin, VideoPlugin, } from '@udecode/plate-media/react'; import { MentionInputPlugin } from '@udecode/plate-mention/react'; @@ -97,7 +96,6 @@ import { MediaEmbedElement } from 'www/src/registry/default/plate-ui/media-embed // @ts-nocheck import { MediaFileElement } from 'www/src/registry/default/plate-ui/media-file-element'; // @ts-nocheck -import { MediaPlaceholderElement } from 'www/src/registry/default/plate-ui/media-placeholder-element'; // @ts-nocheck import { MediaVideoElement } from 'www/src/registry/default/plate-ui/media-video-element'; // @ts-nocheck @@ -166,7 +164,7 @@ export const createPlateUIEditor = < // [MentionPlugin.key]: MentionElement, [NumberedListPlugin.key]: withProps(ListElement, { variant: 'ol' }), [ParagraphPlugin.key]: ParagraphElement, - [PlaceholderPlugin.key]: MediaPlaceholderElement, + // [PlaceholderPlugin.key]: MediaPlaceholderElement, // [SlashInputPlugin.key]: SlashInputElement, [StrikethroughPlugin.key]: withProps(PlateLeaf, { as: 's' }), [SubscriptPlugin.key]: withProps(PlateLeaf, { as: 'sub' }), diff --git a/packages/media/src/react/placeholder/PlaceholderPlugin.tsx b/packages/media/src/react/placeholder/PlaceholderPlugin.tsx index b05c1ed48f..db4d6c4c18 100644 --- a/packages/media/src/react/placeholder/PlaceholderPlugin.tsx +++ b/packages/media/src/react/placeholder/PlaceholderPlugin.tsx @@ -180,12 +180,12 @@ export const PlaceholderPlugin = toTPlatePlugin< if (getNodeString(node).length === 0) { removeNodes(editor, { at: path }); - tf.insert.media(files, { at: path }); + tf.insert.media(files, { at: path, nextBlock: false }); inserted = true; } } if (!inserted) { - tf.insert.media(files); + tf.insert.media(files, { nextBlock: false }); } return true; diff --git a/packages/media/src/react/placeholder/transforms/insertMedia.ts b/packages/media/src/react/placeholder/transforms/insertMedia.ts index 9441de17f9..42afe7dfa3 100644 --- a/packages/media/src/react/placeholder/transforms/insertMedia.ts +++ b/packages/media/src/react/placeholder/transforms/insertMedia.ts @@ -4,6 +4,7 @@ import { type InsertNodesOptions, insertNodes, nanoid, + withoutMergingHistory, withoutNormalizing, } from '@udecode/plate-common'; import { Path } from 'slate'; @@ -94,7 +95,9 @@ export const insertMedia = ( ); if (disableEmptyPlaceholder) { - withHistoryMark(editor, insert); + withoutMergingHistory(editor, () => { + withHistoryMark(editor, insert); + }); } else { withoutNormalizing(editor, insert); } diff --git a/packages/media/src/react/placeholder/utils/history.ts b/packages/media/src/react/placeholder/utils/history.ts index 4e6596472d..e112e00751 100644 --- a/packages/media/src/react/placeholder/utils/history.ts +++ b/packages/media/src/react/placeholder/utils/history.ts @@ -6,9 +6,10 @@ import { PlaceholderPlugin } from '../PlaceholderPlugin'; const historyMarks = new WeakMap(); export const withHistoryMark = (editor: PlateEditor, fn: () => void) => { + const prev = isHistoryMarking(editor); historyMarks.set(editor, true); fn(); - historyMarks.set(editor, false); + historyMarks.set(editor, prev); }; export const isHistoryMarking = (editor: PlateEditor): boolean => { diff --git a/templates/plate-playground-template/next.config.mjs b/templates/plate-playground-template/next.config.mjs index 2527337854..acdde17bae 100644 --- a/templates/plate-playground-template/next.config.mjs +++ b/templates/plate-playground-template/next.config.mjs @@ -4,6 +4,7 @@ const nextConfig = { return [ { destination: '/editor', + permanent: false, source: '/', }, ]; diff --git a/templates/plate-playground-template/package.json b/templates/plate-playground-template/package.json index f8012fee3b..4cea2810d7 100644 --- a/templates/plate-playground-template/package.json +++ b/templates/plate-playground-template/package.json @@ -105,6 +105,7 @@ "tailwindcss-animate": "1.0.7" }, "devDependencies": { + "eslint-plugin-prettier": "^5.2.1", "@types/node": "^22.9.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", diff --git a/templates/plate-playground-template/pnpm-lock.yaml b/templates/plate-playground-template/pnpm-lock.yaml index 03e34bca64..d46fd5d769 100644 --- a/templates/plate-playground-template/pnpm-lock.yaml +++ b/templates/plate-playground-template/pnpm-lock.yaml @@ -309,6 +309,9 @@ importers: eslint-plugin-perfectionist: specifier: 3.9.1 version: 3.9.1(eslint@8.57.1)(svelte@5.2.0)(typescript@5.6.3) + eslint-plugin-prettier: + specifier: ^5.2.1 + version: 5.2.1(eslint-config-prettier@9.1.0(eslint@8.57.1))(eslint@8.57.1)(prettier@3.3.3) eslint-plugin-react: specifier: ^7.37.2 version: 7.37.2(eslint@8.57.1) @@ -2710,6 +2713,20 @@ packages: vue-eslint-parser: optional: true + eslint-plugin-prettier@5.2.1: + resolution: {integrity: sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '*' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + eslint-plugin-react-hooks@5.0.0: resolution: {integrity: sha512-hIOwI+5hYGpJEc4uPRmz2ulCjAGD/N13Lukkh8cLV0i2IRk/bdZDYjgLVHj+U9Z704kLIdIO6iueGvxNur0sgw==} engines: {node: '>=10'} @@ -2793,6 +2810,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -3658,6 +3678,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier-linter-helpers@1.0.0: + resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} + engines: {node: '>=6.0.0'} + prettier-plugin-packagejson@2.5.3: resolution: {integrity: sha512-ATMEEXr+ywls1kgrZEWl4SBPEm0uDdyDAjyNzUC0/Z8WZTD3RqbJcQDR+Dau+wYkW9KHK6zqQIsFyfn+9aduWg==} peerDependencies: @@ -6909,6 +6933,15 @@ snapshots: - supports-color - typescript + eslint-plugin-prettier@5.2.1(eslint-config-prettier@9.1.0(eslint@8.57.1))(eslint@8.57.1)(prettier@3.3.3): + dependencies: + eslint: 8.57.1 + prettier: 3.3.3 + prettier-linter-helpers: 1.0.0 + synckit: 0.9.2 + optionalDependencies: + eslint-config-prettier: 9.1.0(eslint@8.57.1) + eslint-plugin-react-hooks@5.0.0(eslint@8.57.1): dependencies: eslint: 8.57.1 @@ -7034,6 +7067,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-diff@1.3.0: {} + fast-glob@3.3.1: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -8038,6 +8073,10 @@ snapshots: prelude-ls@1.2.1: {} + prettier-linter-helpers@1.0.0: + dependencies: + fast-diff: 1.3.0 + prettier-plugin-packagejson@2.5.3(prettier@3.3.3): dependencies: sort-package-json: 2.10.1 diff --git a/templates/plate-playground-template/src/app/api/uploadthing/route.ts b/templates/plate-playground-template/src/app/api/uploadthing/route.ts index 379d038d96..1a20e0732b 100644 --- a/templates/plate-playground-template/src/app/api/uploadthing/route.ts +++ b/templates/plate-playground-template/src/app/api/uploadthing/route.ts @@ -1,6 +1,29 @@ -import { createRouteHandler } from 'uploadthing/next'; +import type { FileRouter } from 'uploadthing/next'; -import { ourFileRouter } from './core'; +import { createRouteHandler, createUploadthing } from 'uploadthing/next'; + +const f = createUploadthing(); + +// FileRouter for your app, can contain multiple FileRoutes +const ourFileRouter = { + // Define as many FileRoutes as you like, each with a unique routeSlug + imageUploader: f(['image', 'text', 'blob', 'pdf', 'video', 'audio']) + // Set permissions and file types for this FileRoute + .middleware(async ({ req }) => { + // This code runs on your server before upload + + // Whatever is returned here is accessible in onUploadComplete as `metadata` + return {}; + }) + .onUploadComplete(({ file, metadata }) => { + // This code RUNS ON YOUR SERVER after upload + + // !!! Whatever is returned here is sent to the clientside `onClientUploadComplete` callback + return { file }; + }), +} satisfies FileRouter; + +export type OurFileRouter = typeof ourFileRouter; // Export routes for Next App Router export const { GET, POST } = createRouteHandler({ diff --git a/templates/plate-playground-template/src/app/page.tsx b/templates/plate-playground-template/src/app/page.tsx index 433c8aa7fd..7ccfa5451a 100644 --- a/templates/plate-playground-template/src/app/page.tsx +++ b/templates/plate-playground-template/src/app/page.tsx @@ -1,21 +1,21 @@ -import Image from "next/image"; +import Image from 'next/image'; export default function Home() { return ( -
-
+
+
Next.js logo -
    +
    1. - Get started by editing{" "} - + Get started by editing{' '} + app/page.tsx . @@ -23,75 +23,75 @@ export default function Home() {
    2. Save and see your changes instantly.
    -
-