├── .env.example ├── .eslintrc.json ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── next.svg └── vercel.svg ├── src ├── app │ ├── api │ │ └── edgestore │ │ │ └── [...edgestore] │ │ │ └── route.ts │ ├── favicon.ico │ ├── form │ │ └── page.tsx │ ├── globals.css │ ├── layout.tsx │ ├── page.tsx │ └── protected │ │ └── page.tsx ├── components │ ├── multi-file-dropzone.tsx │ └── single-image-dropzone.tsx └── lib │ └── edgestore.ts ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | EDGE_STORE_ACCESS_KEY= 2 | EDGE_STORE_SECRET_KEY= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env* 29 | !.env.example 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // "workbench.colorTheme": "Pitch Black", 3 | // "workbench.colorCustomizations": { 4 | // "editorError.foreground": "#ff000000", 5 | // "editorWarning.foreground": "#ff000000", 6 | // "editorCursor.foreground": "#ff000000", 7 | // "editorInfo.foreground": "#ff000000" 8 | // }, 9 | // "editor.fontSize": 14, 10 | // "editor.fontLigatures": false, 11 | // "editor.renderLineHighlight": "none", 12 | // "editor.matchBrackets": "never", 13 | // "github.copilot.enable": { 14 | // "*": false, 15 | // "yaml": false, 16 | // "plaintext": false, 17 | // "markdown": false 18 | // }, 19 | // "editor.quickSuggestions": { 20 | // "comments": "off", 21 | // "strings": "off", 22 | // "other": "off" 23 | // }, 24 | // "editor.suggestOnTriggerCharacters": false, 25 | // "editor.wordBasedSuggestions": false, 26 | // "editor.parameterHints.enabled": false, 27 | // "gitlens.currentLine.enabled": false, 28 | // "gitlens.currentLine.pullRequests.enabled": false, 29 | // "editor.formatOnSave": false, 30 | // "editor.stickyScroll.enabled": false, 31 | // "window.zoomLevel": 2 32 | } 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## How to run 2 | 3 | ### 1. Create an account 4 | 5 | Create your account at [https://dashboard.edgestore.dev/](https://dashboard.edgestore.dev/) and create a new project. 6 | 7 | ### 2. Run these commands 8 | 9 | ```bash 10 | # Copy the .env.example file to .env 11 | # and fill in the variables 12 | cp .env.example .env 13 | # Install dependencies 14 | npm install 15 | # Run the app 16 | npm run dev 17 | ``` 18 | 19 | ### 3. Test the app 20 | 21 | You should be able to access these endpoints: 22 | 23 | - [http://localhost:3000/](http://localhost:3000/) 24 | - [http://localhost:3000/protected](http://localhost:3000/protected) 25 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | module.exports = nextConfig 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "edgestore-tutorial", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@edgestore/react": "^0.1.3-alpha.2", 13 | "@edgestore/server": "^0.1.3-alpha.2", 14 | "@types/node": "20.5.6", 15 | "@types/react": "18.2.21", 16 | "@types/react-dom": "18.2.7", 17 | "autoprefixer": "10.4.15", 18 | "eslint": "8.48.0", 19 | "eslint-config-next": "13.4.19", 20 | "lucide-react": "^0.270.0", 21 | "next": "13.4.19", 22 | "postcss": "8.4.28", 23 | "react": "18.2.0", 24 | "react-dom": "18.2.0", 25 | "react-dropzone": "^14.2.3", 26 | "tailwind-merge": "^1.14.0", 27 | "tailwindcss": "3.3.3", 28 | "typescript": "5.2.2", 29 | "zod": "^3.22.2" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/api/edgestore/[...edgestore]/route.ts: -------------------------------------------------------------------------------- 1 | import { initEdgeStore } from "@edgestore/server"; 2 | import { 3 | CreateContextOptions, 4 | createEdgeStoreNextHandler, 5 | } from "@edgestore/server/adapters/next/app"; 6 | import { z } from "zod"; 7 | 8 | type Context = { 9 | userId: string; 10 | userRole: "admin" | "user"; 11 | }; 12 | 13 | function createContext({ req }: CreateContextOptions): Context { 14 | // get the session from your auth provider 15 | // const session = getSession(req); 16 | return { 17 | userId: "1234", 18 | userRole: "user", 19 | }; 20 | } 21 | 22 | const es = initEdgeStore.context().create(); 23 | 24 | const edgeStoreRouter = es.router({ 25 | myPublicImages: es 26 | .imageBucket({ 27 | maxSize: 1024 * 1024 * 1, // 1MB 28 | }) 29 | .input( 30 | z.object({ 31 | type: z.enum(["post", "profile"]), 32 | }) 33 | ) 34 | // e.g. /post/my-file.jpg 35 | .path(({ input }) => [{ type: input.type }]), 36 | 37 | myProtectedFiles: es 38 | .fileBucket() 39 | // e.g. /123/my-file.pdf 40 | .path(({ ctx }) => [{ owner: ctx.userId }]) 41 | .accessControl({ 42 | OR: [ 43 | { 44 | userId: { path: "owner" }, 45 | }, 46 | { 47 | userRole: { eq: "admin" }, 48 | }, 49 | ], 50 | }), 51 | }); 52 | 53 | const handler = createEdgeStoreNextHandler({ 54 | router: edgeStoreRouter, 55 | createContext, 56 | }); 57 | 58 | export { handler as GET, handler as POST }; 59 | 60 | export type EdgeStoreRouter = typeof edgeStoreRouter; 61 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/perfectbase/edgestore-tutorial/89a9d41f0fa606abb719a2358ce21c78abd34e07/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/form/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | MultiFileDropzone, 5 | type FileState, 6 | } from "@/components/multi-file-dropzone"; 7 | import { useEdgeStore } from "@/lib/edgestore"; 8 | import { useState } from "react"; 9 | 10 | export default function Page() { 11 | const [fileStates, setFileStates] = useState([]); 12 | const [urls, setUrls] = useState([]); 13 | const [isSubmitted, setIsSubmitted] = useState(false); 14 | const [isCancelled, setIsCancelled] = useState(false); 15 | const { edgestore } = useEdgeStore(); 16 | 17 | function updateFileProgress(key: string, progress: FileState["progress"]) { 18 | setFileStates((fileStates) => { 19 | const newFileStates = structuredClone(fileStates); 20 | const fileState = newFileStates.find( 21 | (fileState) => fileState.key === key 22 | ); 23 | if (fileState) { 24 | fileState.progress = progress; 25 | } 26 | return newFileStates; 27 | }); 28 | } 29 | 30 | if (isSubmitted) { 31 | return
COMPLETE!!!
; 32 | } 33 | if (isCancelled) { 34 | return
CANCELLED!!!
; 35 | } 36 | 37 | return ( 38 |
39 |
40 | { 43 | setFileStates(files); 44 | }} 45 | onFilesAdded={async (addedFiles) => { 46 | setFileStates([...fileStates, ...addedFiles]); 47 | await Promise.all( 48 | addedFiles.map(async (addedFileState) => { 49 | try { 50 | const res = await edgestore.myProtectedFiles.upload({ 51 | file: addedFileState.file, 52 | options: { 53 | temporary: true, 54 | }, 55 | onProgressChange: async (progress) => { 56 | updateFileProgress(addedFileState.key, progress); 57 | if (progress === 100) { 58 | // wait 1 second to set it to complete 59 | // so that the user can see the progress bar at 100% 60 | await new Promise((resolve) => 61 | setTimeout(resolve, 1000) 62 | ); 63 | updateFileProgress(addedFileState.key, "COMPLETE"); 64 | } 65 | }, 66 | }); 67 | setUrls((prev) => [...prev, res.url]); 68 | } catch (err) { 69 | updateFileProgress(addedFileState.key, "ERROR"); 70 | } 71 | }) 72 | ); 73 | }} 74 | /> 75 | {/* Just a dummy form for demo purposes */} 76 |
77 |
Name
78 | 79 |
Description
80 | 81 |
Tags
82 | 83 |
84 | 92 | 105 |
106 |
107 |
108 |
109 | ); 110 | } 111 | 112 | function TextField(props: { 113 | name?: string; 114 | onChange?: (value: string) => void; 115 | }) { 116 | return ( 117 | props.onChange?.(e.target.value)} 122 | /> 123 | ); 124 | } 125 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | body { 20 | color: rgb(var(--foreground-rgb)); 21 | background: linear-gradient( 22 | to bottom, 23 | transparent, 24 | rgb(var(--background-end-rgb)) 25 | ) 26 | rgb(var(--background-start-rgb)); 27 | } 28 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { EdgeStoreProvider } from '@/lib/edgestore' 2 | import './globals.css' 3 | import type { Metadata } from 'next' 4 | import { Inter } from 'next/font/google' 5 | 6 | const inter = Inter({ subsets: ['latin'] }) 7 | 8 | export const metadata: Metadata = { 9 | title: 'Create Next App', 10 | description: 'Generated by create next app', 11 | } 12 | 13 | export default function RootLayout({ 14 | children, 15 | }: { 16 | children: React.ReactNode 17 | }) { 18 | return ( 19 | 20 | 21 | {children} 22 | 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SingleImageDropzone } from "@/components/single-image-dropzone"; 4 | import { useEdgeStore } from "@/lib/edgestore"; 5 | import Link from "next/link"; 6 | import { useState } from "react"; 7 | 8 | export default function Page() { 9 | const [file, setFile] = useState(); 10 | const [progress, setProgress] = useState(0); 11 | const [urls, setUrls] = useState<{ 12 | url: string; 13 | thumbnailUrl: string | null; 14 | }>(); 15 | const { edgestore } = useEdgeStore(); 16 | 17 | return ( 18 |
19 | { 27 | setFile(file); 28 | }} 29 | /> 30 |
31 |
37 |
38 | 59 | {urls?.url && ( 60 | 61 | URL 62 | 63 | )} 64 | {urls?.thumbnailUrl && ( 65 | 66 | THUMBNAIL 67 | 68 | )} 69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/app/protected/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | MultiFileDropzone, 5 | type FileState, 6 | } from "@/components/multi-file-dropzone"; 7 | import { useEdgeStore } from "@/lib/edgestore"; 8 | import Link from "next/link"; 9 | import { useState } from "react"; 10 | 11 | export default function Page() { 12 | const [fileStates, setFileStates] = useState([]); 13 | const [urls, setUrls] = useState([]); 14 | const { edgestore } = useEdgeStore(); 15 | 16 | function updateFileProgress(key: string, progress: FileState["progress"]) { 17 | setFileStates((fileStates) => { 18 | const newFileStates = structuredClone(fileStates); 19 | const fileState = newFileStates.find( 20 | (fileState) => fileState.key === key 21 | ); 22 | if (fileState) { 23 | fileState.progress = progress; 24 | } 25 | return newFileStates; 26 | }); 27 | } 28 | 29 | return ( 30 |
31 | { 34 | setFileStates(files); 35 | }} 36 | onFilesAdded={async (addedFiles) => { 37 | setFileStates([...fileStates, ...addedFiles]); 38 | await Promise.all( 39 | addedFiles.map(async (addedFileState) => { 40 | try { 41 | const res = await edgestore.myProtectedFiles.upload({ 42 | file: addedFileState.file, 43 | onProgressChange: async (progress) => { 44 | updateFileProgress(addedFileState.key, progress); 45 | if (progress === 100) { 46 | // wait 1 second to set it to complete 47 | // so that the user can see the progress bar at 100% 48 | await new Promise((resolve) => setTimeout(resolve, 1000)); 49 | updateFileProgress(addedFileState.key, "COMPLETE"); 50 | } 51 | }, 52 | }); 53 | setUrls([...urls, res.url]); 54 | console.log(res); 55 | } catch (err) { 56 | updateFileProgress(addedFileState.key, "ERROR"); 57 | } 58 | }) 59 | ); 60 | }} 61 | /> 62 | {urls.map((url, index) => ( 63 | 64 | URL{index + 1} 65 | 66 | ))} 67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /src/components/multi-file-dropzone.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | CheckCircleIcon, 5 | FileIcon, 6 | LucideFileWarning, 7 | Trash2Icon, 8 | UploadCloudIcon, 9 | } from 'lucide-react'; 10 | import * as React from 'react'; 11 | import { DropzoneOptions, useDropzone } from 'react-dropzone'; 12 | import { twMerge } from 'tailwind-merge'; 13 | 14 | const variants = { 15 | base: 'relative rounded-md p-4 w-96 flex justify-center items-center flex-col cursor-pointer border border-dashed border-gray-300 transition-colors duration-200 ease-in-out', 16 | active: 'border-2', 17 | disabled: 18 | 'bg-gray-700 border-white/20 cursor-default pointer-events-none bg-opacity-30', 19 | accept: 'border border-blue-500 bg-blue-500 bg-opacity-10', 20 | reject: 'border border-red-700 bg-red-700 bg-opacity-10', 21 | }; 22 | 23 | export type FileState = { 24 | file: File; 25 | key: string; // used to identify the file in the progress callback 26 | progress: 'PENDING' | 'COMPLETE' | 'ERROR' | number; 27 | }; 28 | 29 | type InputProps = { 30 | className?: string; 31 | value?: FileState[]; 32 | onChange?: (files: FileState[]) => void | Promise; 33 | onFilesAdded?: (addedFiles: FileState[]) => void | Promise; 34 | disabled?: boolean; 35 | dropzoneOptions?: Omit; 36 | }; 37 | 38 | const ERROR_MESSAGES = { 39 | fileTooLarge(maxSize: number) { 40 | return `The file is too large. Max size is ${formatFileSize(maxSize)}.`; 41 | }, 42 | fileInvalidType() { 43 | return 'Invalid file type.'; 44 | }, 45 | tooManyFiles(maxFiles: number) { 46 | return `You can only add ${maxFiles} file(s).`; 47 | }, 48 | fileNotSupported() { 49 | return 'The file is not supported.'; 50 | }, 51 | }; 52 | 53 | const MultiFileDropzone = React.forwardRef( 54 | ( 55 | { dropzoneOptions, value, className, disabled, onFilesAdded, onChange }, 56 | ref, 57 | ) => { 58 | const [customError, setCustomError] = React.useState(); 59 | if (dropzoneOptions?.maxFiles && value?.length) { 60 | disabled = disabled ?? value.length >= dropzoneOptions.maxFiles; 61 | } 62 | // dropzone configuration 63 | const { 64 | getRootProps, 65 | getInputProps, 66 | fileRejections, 67 | isFocused, 68 | isDragAccept, 69 | isDragReject, 70 | } = useDropzone({ 71 | disabled, 72 | onDrop: (acceptedFiles) => { 73 | const files = acceptedFiles; 74 | setCustomError(undefined); 75 | if ( 76 | dropzoneOptions?.maxFiles && 77 | (value?.length ?? 0) + files.length > dropzoneOptions.maxFiles 78 | ) { 79 | setCustomError(ERROR_MESSAGES.tooManyFiles(dropzoneOptions.maxFiles)); 80 | return; 81 | } 82 | if (files) { 83 | const addedFiles = files.map((file) => ({ 84 | file, 85 | key: Math.random().toString(36).slice(2), 86 | progress: 'PENDING', 87 | })); 88 | void onFilesAdded?.(addedFiles); 89 | void onChange?.([...(value ?? []), ...addedFiles]); 90 | } 91 | }, 92 | ...dropzoneOptions, 93 | }); 94 | 95 | // styling 96 | const dropZoneClassName = React.useMemo( 97 | () => 98 | twMerge( 99 | variants.base, 100 | isFocused && variants.active, 101 | disabled && variants.disabled, 102 | (isDragReject ?? fileRejections[0]) && variants.reject, 103 | isDragAccept && variants.accept, 104 | className, 105 | ).trim(), 106 | [ 107 | isFocused, 108 | fileRejections, 109 | isDragAccept, 110 | isDragReject, 111 | disabled, 112 | className, 113 | ], 114 | ); 115 | 116 | // error validation messages 117 | const errorMessage = React.useMemo(() => { 118 | if (fileRejections[0]) { 119 | const { errors } = fileRejections[0]; 120 | if (errors[0]?.code === 'file-too-large') { 121 | return ERROR_MESSAGES.fileTooLarge(dropzoneOptions?.maxSize ?? 0); 122 | } else if (errors[0]?.code === 'file-invalid-type') { 123 | return ERROR_MESSAGES.fileInvalidType(); 124 | } else if (errors[0]?.code === 'too-many-files') { 125 | return ERROR_MESSAGES.tooManyFiles(dropzoneOptions?.maxFiles ?? 0); 126 | } else { 127 | return ERROR_MESSAGES.fileNotSupported(); 128 | } 129 | } 130 | return undefined; 131 | }, [fileRejections, dropzoneOptions]); 132 | 133 | return ( 134 |
135 |
136 |
137 | {/* Main File Input */} 138 |
143 | 144 |
145 | 146 |
147 | drag & drop or click to upload 148 |
149 |
150 |
151 | 152 | {/* Error Text */} 153 |
154 | {customError ?? errorMessage} 155 |
156 |
157 | 158 | {/* Selected Files */} 159 | {value?.map(({ file, progress }, i) => ( 160 |
164 |
165 | 166 |
167 |
168 | {file.name} 169 |
170 |
171 | {formatFileSize(file.size)} 172 |
173 |
174 |
175 |
176 | {progress === 'PENDING' ? ( 177 | 187 | ) : progress === 'ERROR' ? ( 188 | 189 | ) : progress !== 'COMPLETE' ? ( 190 |
{Math.round(progress)}%
191 | ) : ( 192 | 193 | )} 194 |
195 |
196 | {/* Progress Bar */} 197 | {typeof progress === 'number' && ( 198 |
199 |
200 |
206 |
207 |
208 | )} 209 |
210 | ))} 211 |
212 |
213 | ); 214 | }, 215 | ); 216 | MultiFileDropzone.displayName = 'MultiFileDropzone'; 217 | 218 | function formatFileSize(bytes?: number) { 219 | if (!bytes) { 220 | return '0 Bytes'; 221 | } 222 | bytes = Number(bytes); 223 | if (bytes === 0) { 224 | return '0 Bytes'; 225 | } 226 | const k = 1024; 227 | const dm = 2; 228 | const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; 229 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 230 | return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; 231 | } 232 | 233 | export { MultiFileDropzone }; -------------------------------------------------------------------------------- /src/components/single-image-dropzone.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { UploadCloudIcon, X } from 'lucide-react'; 4 | import * as React from 'react'; 5 | import { DropzoneOptions, useDropzone } from 'react-dropzone'; 6 | import { twMerge } from 'tailwind-merge'; 7 | 8 | const variants = { 9 | base: 'relative rounded-md flex justify-center items-center flex-col cursor-pointer min-h-[150px] min-w-[200px] border border-dashed border-gray-300 transition-colors duration-200 ease-in-out', 10 | image: 11 | 'border-0 p-0 min-h-0 min-w-0 relative shadow-md bg-slate-900 rounded-md', 12 | active: 'border-2', 13 | disabled: 'bg-gray-700 cursor-default pointer-events-none bg-opacity-30', 14 | accept: 'border border-blue-500 bg-blue-500 bg-opacity-10', 15 | reject: 'border border-red-700 bg-red-700 bg-opacity-10', 16 | }; 17 | 18 | type InputProps = { 19 | width: number; 20 | height: number; 21 | className?: string; 22 | value?: File | string; 23 | onChange?: (file?: File) => void | Promise; 24 | disabled?: boolean; 25 | dropzoneOptions?: Omit; 26 | }; 27 | 28 | const ERROR_MESSAGES = { 29 | fileTooLarge(maxSize: number) { 30 | return `The file is too large. Max size is ${formatFileSize(maxSize)}.`; 31 | }, 32 | fileInvalidType() { 33 | return 'Invalid file type.'; 34 | }, 35 | tooManyFiles(maxFiles: number) { 36 | return `You can only add ${maxFiles} file(s).`; 37 | }, 38 | fileNotSupported() { 39 | return 'The file is not supported.'; 40 | }, 41 | }; 42 | 43 | const SingleImageDropzone = React.forwardRef( 44 | ( 45 | { dropzoneOptions, width, height, value, className, disabled, onChange }, 46 | ref, 47 | ) => { 48 | const imageUrl = React.useMemo(() => { 49 | if (typeof value === 'string') { 50 | // in case a url is passed in, use it to display the image 51 | return value; 52 | } else if (value) { 53 | // in case a file is passed in, create a base64 url to display the image 54 | return URL.createObjectURL(value); 55 | } 56 | return null; 57 | }, [value]); 58 | 59 | // dropzone configuration 60 | const { 61 | getRootProps, 62 | getInputProps, 63 | acceptedFiles, 64 | fileRejections, 65 | isFocused, 66 | isDragAccept, 67 | isDragReject, 68 | } = useDropzone({ 69 | accept: { 'image/*': [] }, 70 | multiple: false, 71 | disabled, 72 | onDrop: (acceptedFiles) => { 73 | const file = acceptedFiles[0]; 74 | if (file) { 75 | void onChange?.(file); 76 | } 77 | }, 78 | ...dropzoneOptions, 79 | }); 80 | 81 | // styling 82 | const dropZoneClassName = React.useMemo( 83 | () => 84 | twMerge( 85 | variants.base, 86 | isFocused && variants.active, 87 | disabled && variants.disabled, 88 | imageUrl && variants.image, 89 | (isDragReject ?? fileRejections[0]) && variants.reject, 90 | isDragAccept && variants.accept, 91 | className, 92 | ).trim(), 93 | [ 94 | isFocused, 95 | imageUrl, 96 | fileRejections, 97 | isDragAccept, 98 | isDragReject, 99 | disabled, 100 | className, 101 | ], 102 | ); 103 | 104 | // error validation messages 105 | const errorMessage = React.useMemo(() => { 106 | if (fileRejections[0]) { 107 | const { errors } = fileRejections[0]; 108 | if (errors[0]?.code === 'file-too-large') { 109 | return ERROR_MESSAGES.fileTooLarge(dropzoneOptions?.maxSize ?? 0); 110 | } else if (errors[0]?.code === 'file-invalid-type') { 111 | return ERROR_MESSAGES.fileInvalidType(); 112 | } else if (errors[0]?.code === 'too-many-files') { 113 | return ERROR_MESSAGES.tooManyFiles(dropzoneOptions?.maxFiles ?? 0); 114 | } else { 115 | return ERROR_MESSAGES.fileNotSupported(); 116 | } 117 | } 118 | return undefined; 119 | }, [fileRejections, dropzoneOptions]); 120 | 121 | return ( 122 |
123 |
132 | {/* Main File Input */} 133 | 134 | 135 | {imageUrl ? ( 136 | // Image Preview 137 | {acceptedFiles[0]?.name} 142 | ) : ( 143 | // Upload Icon 144 |
145 | 146 |
drag & drop to upload
147 |
148 | 149 |
150 |
151 | )} 152 | 153 | {/* Remove Image Icon */} 154 | {imageUrl && !disabled && ( 155 |
{ 158 | e.stopPropagation(); 159 | void onChange?.(undefined); 160 | }} 161 | > 162 |
163 | 164 |
165 |
166 | )} 167 |
168 | 169 | {/* Error Text */} 170 |
{errorMessage}
171 |
172 | ); 173 | }, 174 | ); 175 | SingleImageDropzone.displayName = 'SingleImageDropzone'; 176 | 177 | const Button = React.forwardRef< 178 | HTMLButtonElement, 179 | React.ButtonHTMLAttributes 180 | >(({ className, ...props }, ref) => { 181 | return ( 182 |