@@ -5,8 +5,19 @@ import { Button } from './ui/button';
55import { Input } from './ui/input' ;
66import Image from 'next/image' ;
77import { ImageIcon , Trash2 } from 'lucide-react' ;
8- import { ChangeEvent , useRef } from 'react' ;
8+ import {
9+ ChangeEvent ,
10+ DragEvent ,
11+ MouseEvent ,
12+ useCallback ,
13+ useEffect ,
14+ useMemo ,
15+ useRef ,
16+ useState ,
17+ } from 'react' ;
918import { ControllerRenderProps } from 'react-hook-form' ;
19+ import { toast } from 'sonner' ;
20+ import { cn } from '@/lib/utils' ;
1021
1122export function ImageInput ( {
1223 field,
@@ -18,67 +29,129 @@ export function ImageInput({
1829 setImagePreview : ( image : string ) => void ;
1930} ) {
2031 const imageFileInput = useRef < HTMLInputElement > ( null ) ;
32+ const [ isDragOver , setIsDragOver ] = useState < boolean > ( false ) ;
2133
22- function handleImageChange ( file : File ) {
23- if ( file ) {
24- const reader = new FileReader ( ) ;
34+ const reader = useMemo ( ( ) => new FileReader ( ) , [ ] ) ;
35+
36+ const setImage = useCallback (
37+ ( file : File ) => {
38+ field . onChange ( file ) ;
2539 reader . readAsDataURL ( file ) ;
26- reader . onloadend = ( ) => {
27- setImagePreview ( reader . result as string ) ;
28- } ;
29- }
30- }
40+ } ,
41+ [ field , reader ]
42+ ) ;
3143
32- function handleImageDelete ( ) {
44+ function removeImage ( e : MouseEvent < HTMLButtonElement > ) {
45+ e . stopPropagation ( ) ;
3346 setImagePreview ( '' ) ;
3447 }
3548
49+ const handleChange = useCallback (
50+ ( file : File | undefined ) => {
51+ if ( ! file ) {
52+ toast . error ( 'No file uploaded.' ) ;
53+ } else if ( ACCEPTED_IMAGE_TYPES . includes ( file . type ) ) {
54+ setImage ( file ) ;
55+ } else {
56+ toast . error ( 'Please upload a .jpg, .jpeg, .png or .svg file.' ) ;
57+ }
58+ } ,
59+ [ setImage ]
60+ ) ;
61+
62+ const handleDragOver = useCallback ( ( e : DragEvent ) => {
63+ e . preventDefault ( ) ;
64+ setIsDragOver ( true ) ;
65+ } , [ ] ) ;
66+
67+ const handleDragLeave = useCallback ( ( e : DragEvent ) => {
68+ e . preventDefault ( ) ;
69+ setIsDragOver ( false ) ;
70+ } , [ ] ) ;
71+
72+ const handleDrop = useCallback (
73+ ( e : DragEvent ) => {
74+ e . preventDefault ( ) ;
75+ setIsDragOver ( false ) ;
76+
77+ handleChange ( e . dataTransfer . files [ 0 ] ) ;
78+ } ,
79+ [ handleChange ]
80+ ) ;
81+
82+ const handleLoad = useCallback ( ( ) => {
83+ setImagePreview ( reader . result as string ) ;
84+ } , [ setImagePreview , reader ] ) ;
85+
86+ const handleError = useCallback ( ( ) => {
87+ toast . error ( 'Unable to upload image.' ) ;
88+ } , [ ] ) ;
89+
90+ useEffect ( ( ) => {
91+ reader . addEventListener ( 'load' , handleLoad ) ;
92+ reader . addEventListener ( 'error' , handleError ) ;
93+
94+ return ( ) => {
95+ reader . removeEventListener ( 'load' , handleLoad ) ;
96+ reader . removeEventListener ( 'error' , handleError ) ;
97+ } ;
98+ } , [ reader , setImagePreview , handleLoad , handleError ] ) ;
99+
36100 return (
37101 < div className = "relative flex justify-between gap-x-4" >
38102 < Button
39103 type = "button"
40- className = "relative flex size-32 items-center justify-center rounded-lg border bg-background p-0 hover:bg-background"
104+ className = { cn (
105+ 'relative flex size-32 flex-col items-center justify-center gap-1 rounded-lg border bg-background p-0 transition-colors hover:bg-background' ,
106+ isDragOver
107+ ? 'border-primary bg-primary/20'
108+ : 'border-border bg-background hover:bg-background'
109+ ) }
41110 onClick = { ( ) => imageFileInput . current ?. click ( ) }
111+ onDragOver = { handleDragOver }
112+ onDragLeave = { handleDragLeave }
113+ onDrop = { handleDrop }
42114 >
43115 < Input
44116 type = "file"
45- accept = { ACCEPTED_IMAGE_TYPES . join ( ',' ) }
46117 className = "pointer-events-none absolute size-full cursor-pointer select-none opacity-0"
47118 tabIndex = { - 1 }
48119 ref = { ( e ) => {
49120 field . ref ( e ) ;
50121 imageFileInput . current = e ;
51122 } }
52123 onChange = { ( e : ChangeEvent < HTMLInputElement > ) => {
53- const file = e . target . files ?. [ 0 ] ;
54- field . onChange ( file ) ;
55- if ( file ) {
56- handleImageChange ( file ) ;
57- }
124+ handleChange ( e . target . files ?. [ 0 ] ) ;
58125 } }
59126 onBlur = { field . onBlur }
60127 />
61128 { imagePreview ? (
62- < Image
63- src = { imagePreview }
64- alt = "Preview"
65- className = "pointer-events-none size-full rounded-lg object-cover"
66- fill
67- />
129+ < >
130+ < Image
131+ src = { imagePreview }
132+ alt = "Preview"
133+ className = "pointer-events-none rounded-lg object-cover"
134+ fill
135+ />
136+ < Button
137+ type = "button"
138+ variant = "ghost"
139+ size = "icon"
140+ className = "absolute bottom-1 right-1 size-8 rounded-full border-[1px] border-background bg-foreground p-1 hover:bg-primary"
141+ onClick = { removeImage }
142+ >
143+ < Trash2 className = "text-background" />
144+ </ Button >
145+ </ >
68146 ) : (
69- < ImageIcon className = "text-muted-foreground" />
147+ < >
148+ < ImageIcon className = "text-muted-foreground" />
149+ < span className = "text-xs text-muted-foreground" >
150+ Browse or Drop
151+ </ span >
152+ </ >
70153 ) }
71154 </ Button >
72- { imagePreview && (
73- < Button
74- type = "button"
75- variant = "ghost"
76- size = "icon"
77- onClick = { handleImageDelete }
78- >
79- < Trash2 className = "size-4" />
80- </ Button >
81- ) }
82155 </ div >
83156 ) ;
84157}
0 commit comments