Skip to content

Commit 3386d5e

Browse files
committed
Add drag and drop for ImageInput
1 parent d8ed55d commit 3386d5e

File tree

1 file changed

+107
-34
lines changed

1 file changed

+107
-34
lines changed

app/src/components/ImageInput.tsx

Lines changed: 107 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,19 @@ import { Button } from './ui/button';
55
import { Input } from './ui/input';
66
import Image from 'next/image';
77
import { 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';
918
import { ControllerRenderProps } from 'react-hook-form';
19+
import { toast } from 'sonner';
20+
import { cn } from '@/lib/utils';
1021

1122
export 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

Comments
 (0)