Skip to content

Commit f00379f

Browse files
RileranDecampsRenan
authored andcommitted
feat: first version of ImageUpload component
1 parent 820bc69 commit f00379f

File tree

6 files changed

+236
-0
lines changed

6 files changed

+236
-0
lines changed

.env.example

+5
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,8 @@ NEXT_PUBLIC_API_BASE_URL=
55
# Use the following environment variables to show the environment name.
66
NEXT_PUBLIC_DEV_ENV_NAME=staging
77
NEXT_PUBLIC_DEV_ENV_COLOR_SCHEME=teal
8+
9+
# Cloudinary integration
10+
# Make sure that the upload preset supports unsigned upload
11+
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=
12+
NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET=
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { chakra } from '@chakra-ui/react';
2+
3+
export const DefaultImagePlaceholder: React.FC = (props) => (
4+
<chakra.svg width="auto" viewBox="0 0 1600 900" bgColor="#F1F5F9" {...props}>
5+
<g clipPath="url(#a)">
6+
<path d="M1600 0 0 900M0 0l1600 900" stroke="#CBD5E1" strokeWidth={5} />
7+
<path fill="#F1F5F9" d="M616 266h368v368H616z" />
8+
<path
9+
fillRule="evenodd"
10+
clipRule="evenodd"
11+
d="M691.875 327.5c-8.491 0-15.375 6.884-15.375 15.375v215.25c0 8.491 6.884 15.375 15.375 15.375h215.25c8.491 0 15.375-6.884 15.375-15.375v-215.25c0-8.491-6.884-15.375-15.375-15.375h-215.25Zm-46.125 15.375c0-25.474 20.651-46.125 46.125-46.125h215.25c25.474 0 46.125 20.651 46.125 46.125v215.25c0 25.474-20.651 46.125-46.125 46.125h-215.25c-25.474 0-46.125-20.651-46.125-46.125v-215.25Z"
12+
fill="#94A3B8"
13+
/>
14+
<path
15+
fillRule="evenodd"
16+
clipRule="evenodd"
17+
d="M745.688 389a7.688 7.688 0 1 0 0 15.376 7.688 7.688 0 0 0 0-15.376Zm-38.438 7.688c0-21.229 17.209-38.438 38.438-38.438 21.228 0 38.437 17.209 38.437 38.438 0 21.228-17.209 38.437-38.437 38.437-21.229 0-38.438-17.209-38.438-38.437ZM850.128 408.878c6.005-6.004 15.739-6.004 21.744 0l76.875 76.875c6.004 6.005 6.004 15.739 0 21.744-6.005 6.004-15.739 6.004-21.744 0L861 441.494 702.747 599.747c-6.005 6.004-15.739 6.004-21.744 0-6.004-6.004-6.004-15.739 0-21.744l169.125-169.125Z"
18+
fill="#94A3B8"
19+
/>
20+
</g>
21+
<defs>
22+
<clipPath id="a">
23+
<path fill="#fff" d="M0 0h1600v900H0z" />
24+
</clipPath>
25+
</defs>
26+
</chakra.svg>
27+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import axios from 'axios';
2+
3+
const CLOUDINARY_UPLOAD_ENDPOINT = `https://api.cloudinary.com/v1_1/${process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME}/image/upload`;
4+
5+
/**
6+
* Upload an image file to cloudinary using unsigned upload.
7+
*
8+
* See .env.example for cloud name and upload preset configuration
9+
*
10+
* @param file The image to upload to cloudinary
11+
* @returns A URL link to the image on cloudinary
12+
*/
13+
export const uploadFile = async (file: File): Promise<string> => {
14+
const formData = new FormData();
15+
16+
formData.append('file', file);
17+
formData.append(
18+
'upload_preset',
19+
process.env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET ?? ''
20+
);
21+
22+
return (
23+
await axios.post<{ secure_url: string }>(
24+
CLOUDINARY_UPLOAD_ENDPOINT,
25+
formData
26+
)
27+
)?.secure_url;
28+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React, { useState } from 'react';
2+
3+
import { Center, Stack, Text } from '@chakra-ui/react';
4+
import { FiImage } from 'react-icons/fi';
5+
6+
import { Icon } from '@/components/Icons';
7+
import { ImageUpload } from '@/components/ImageUpload';
8+
9+
export default {
10+
title: 'Components/ImageUpload',
11+
};
12+
13+
export const Default = () => {
14+
const [imageUrl, setImageUrl] = useState<string>('');
15+
16+
return (
17+
<Stack>
18+
<ImageUpload value={imageUrl} onChange={setImageUrl} w="240px" />
19+
<ImageUpload value={imageUrl} onChange={setImageUrl} w="360px" />
20+
<ImageUpload
21+
value={imageUrl}
22+
onChange={setImageUrl}
23+
w="480px"
24+
ratio={1}
25+
/>
26+
</Stack>
27+
);
28+
};
29+
30+
export const CustomPlaceholder = () => {
31+
const PlaceholderComponent = () => (
32+
<Center bgColor="gray.50" overflow="hidden">
33+
<Stack textAlign="center" spacing={2}>
34+
<Icon
35+
fontSize="48px"
36+
textColor="gray.400"
37+
icon={FiImage}
38+
alignSelf="center"
39+
/>
40+
<Text textColor="gray.600" fontWeight="medium" fontSize="md">
41+
Upload a photo
42+
</Text>
43+
</Stack>
44+
</Center>
45+
);
46+
47+
const [imageUrl, setImageUrl] = useState<string>('');
48+
49+
return (
50+
<Stack>
51+
<ImageUpload
52+
value={imageUrl}
53+
onChange={setImageUrl}
54+
placeholder={<PlaceholderComponent />}
55+
w="360px"
56+
/>
57+
</Stack>
58+
);
59+
};

src/components/ImageUpload/index.tsx

+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import React, { useRef, useState } from 'react';
2+
3+
import {
4+
AspectRatio,
5+
AspectRatioProps,
6+
Box,
7+
BoxProps,
8+
IconButton,
9+
Image,
10+
ImageProps,
11+
Input,
12+
} from '@chakra-ui/react';
13+
import { FiX } from 'react-icons/fi';
14+
15+
import { Loader } from '@/app/layout';
16+
import { DefaultImagePlaceholder } from '@/components/ImageUpload/DefaultImagePlaceholder';
17+
import { uploadFile } from '@/components/ImageUpload/cloudinary.service';
18+
19+
export type ImageUploadProps = Omit<BoxProps, 'onChange' | 'placeholder'> &
20+
Pick<AspectRatioProps, 'ratio'> & {
21+
value: string;
22+
onChange: (url: string) => void;
23+
onUploadStateChange?: (isUploading: boolean) => void;
24+
placeholder?: ImageProps['fallback'];
25+
};
26+
27+
export const ImageUpload: React.FC<ImageUploadProps> = ({
28+
value,
29+
onChange,
30+
onUploadStateChange = () => undefined,
31+
placeholder = undefined,
32+
ratio = 16 / 9,
33+
...rest
34+
}) => {
35+
const fileInputRef = useRef<HTMLInputElement>(null);
36+
const [isUploading, setIsUploading] = useState(false);
37+
38+
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
39+
const file = e.target.files?.[0];
40+
if (!file) {
41+
return;
42+
}
43+
44+
try {
45+
setIsUploading(true);
46+
onUploadStateChange(true);
47+
48+
const imageUrl = await uploadFile(file);
49+
console.log({ imageUrl });
50+
onChange(imageUrl);
51+
} catch (err) {
52+
console.error(err);
53+
} finally {
54+
onUploadStateChange(false);
55+
setIsUploading(false);
56+
}
57+
};
58+
59+
const handleDelete = () => {
60+
onChange('');
61+
if (fileInputRef.current) {
62+
fileInputRef.current.value = '';
63+
}
64+
};
65+
66+
const getFallback = () => {
67+
// If uploading to cloudinary or loading the image from the url, display
68+
// the loader.
69+
if (value || isUploading) {
70+
return <Loader />;
71+
}
72+
73+
return placeholder ?? <DefaultImagePlaceholder />;
74+
};
75+
76+
return (
77+
<Box
78+
position="relative"
79+
border="1px"
80+
borderStyle="dashed"
81+
borderColor="gray.200"
82+
borderRadius="16px"
83+
overflow="hidden"
84+
cursor="pointer"
85+
transition="border-color 150ms ease-in-out"
86+
_hover={{ borderColor: 'gray.400' }}
87+
{...rest}
88+
>
89+
<Input
90+
ref={fileInputRef}
91+
type="file"
92+
display="none"
93+
onChange={handleChange}
94+
/>
95+
<AspectRatio ratio={ratio} onClick={() => fileInputRef.current?.click()}>
96+
<Image src={value} fallback={getFallback()} />
97+
</AspectRatio>
98+
{!!value && (
99+
<IconButton
100+
icon={<FiX />}
101+
position="absolute"
102+
top="0"
103+
right="0"
104+
size="lg"
105+
variant="ghost"
106+
aria-label="Remove photo"
107+
onClick={handleDelete}
108+
_active={{ bgColor: 'blackAlpha.600' }}
109+
_hover={{ bgColor: 'blackAlpha.300' }}
110+
/>
111+
)}
112+
</Box>
113+
);
114+
};

src/mocks/server.ts

+3
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ export const mockServer = () => {
3838

3939
this.namespace = '/';
4040
this.passthrough();
41+
42+
// Enable image upload to cloudinary, even when developing with mocks
43+
this.passthrough('https://api.cloudinary.com/**');
4144
},
4245
});
4346
};

0 commit comments

Comments
 (0)