diff --git a/apps/shelter/src/pages/settings/account/index.tsx b/apps/shelter/src/pages/settings/account/index.tsx index e9730a0e..62cf98d6 100644 --- a/apps/shelter/src/pages/settings/account/index.tsx +++ b/apps/shelter/src/pages/settings/account/index.tsx @@ -11,8 +11,9 @@ import { useToast, } from '@chakra-ui/react'; import { useMutation } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { useForm } from 'react-hook-form'; +import { usePhotoUpload } from 'shared/hooks/usePhotoUpload'; import { updateShelterInfo } from '@/apis/shelter'; import { UpdateShelterInfo } from '@/types/apis/shetler'; @@ -21,7 +22,6 @@ import useFetchShelterAccount from './_hooks/useFetchShelterAccount'; export default function SettingsAccountPage() { const toast = useToast(); - const [imgFile, setImgFile] = useState(''); const { data } = useFetchShelterAccount(); const { mutate: updateShelter } = useMutation({ mutationFn: (data: UpdateShelterInfo) => updateShelterInfo(data), @@ -36,22 +36,21 @@ export default function SettingsAccountPage() { }); const { register, handleSubmit, reset, watch } = useForm(); + const { photo, setPhoto, handleUploadPhoto } = usePhotoUpload(data.imageUrl); useEffect(() => { reset(data); - setImgFile(data.imageUrl); + setPhoto(data.imageUrl); }, [data]); const onSubmit = handleSubmit((newData) => { updateShelter(newData); }); - const uploadImgFile = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - const newImgFile = URL.createObjectURL(file); - setImgFile(newImgFile); - } + const uploadImgFile = (event: React.ChangeEvent) => { + const { files } = event.target; + handleUploadPhoto(files); + event.target.value = ''; }; return ( @@ -64,7 +63,7 @@ export default function SettingsAccountPage() { as="label" htmlFor="profileImg" cursor="pointer" - src={imgFile} + src={photo} /> (''); const { data } = useFetchMyVolunteer(); const { mutate: updateAccount } = useMutation({ mutationFn: (data: UpdateUserInfoParams) => updateVolunteerUserInfo(data), @@ -38,6 +38,9 @@ export default function SettingsAccountPage() { }); const { register, handleSubmit, reset, watch } = useForm(); + const { photo, setPhoto, handleUploadPhoto } = usePhotoUpload( + data.volunteerImageUrl, + ); useEffect(() => { reset({ @@ -47,15 +50,13 @@ export default function SettingsAccountPage() { phoneNumber: data.volunteerPhoneNumber, gender: data.volunteerGender, }); - setImgFile(data.volunteerImageUrl); + setPhoto(data.volunteerImageUrl); }, [data, reset]); - const uploadImgFile = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - const newImgFile = URL.createObjectURL(file); - setImgFile(newImgFile); - } + const uploadImgFile = (event: React.ChangeEvent) => { + const { files } = event.target; + handleUploadPhoto(files); + event.target.value = ''; }; const onSubmit = handleSubmit((newData) => { @@ -71,7 +72,7 @@ export default function SettingsAccountPage() { as="label" htmlFor="profileImg" cursor="pointer" - src={imgFile} + src={photo} variant="프로필 이미지" /> - + ); } diff --git a/packages/shared/components/Loader.tsx b/packages/shared/components/Loader.tsx new file mode 100644 index 00000000..d794a803 --- /dev/null +++ b/packages/shared/components/Loader.tsx @@ -0,0 +1,15 @@ +import { Flex, Spinner } from '@chakra-ui/react'; + +export default function Loader() { + return ( + + + + ); +} diff --git a/packages/shared/hooks/usePhotoUpload.ts b/packages/shared/hooks/usePhotoUpload.ts new file mode 100644 index 00000000..fdec278a --- /dev/null +++ b/packages/shared/hooks/usePhotoUpload.ts @@ -0,0 +1,60 @@ +import { useToast, UseToastOptions } from '@chakra-ui/react'; +import { useState } from 'react'; + +import { uploadImage } from '../apis/common/Image'; +import { resizeImageFile } from '../utils/image'; + +const toastOption = ( + description: string, + status: UseToastOptions['status'], +): UseToastOptions => { + return { + description, + position: 'top', + status, + duration: 1500, + isClosable: true, + }; +}; + +export const usePhotoUpload = (initialPhoto?: string) => { + const [photo, setPhoto] = useState(initialPhoto); + + const toast = useToast(); + + const handleUploadPhoto = async (files: FileList | null) => { + if (!files) { + return; + } + + if (files.length !== 1) { + toast(toastOption('프로필 이미지는 한 장만 등록 가능합니다.', 'error')); + + return; + } + + const imageFile = files[0]; + const localImageUrl = URL.createObjectURL(imageFile); + setPhoto(localImageUrl); + + const resizedImage = await resizeImageFile(imageFile, 2); + const formData = new FormData(); + formData.append('images', resizedImage); + + const { data } = await uploadImage(formData); + const [imageUrl] = data.imageUrls; + + setPhoto(imageUrl); + }; + + const handleDeletePhoto = () => { + setPhoto(undefined); + }; + + return { + photo, + setPhoto, + handleUploadPhoto, + handleDeletePhoto, + }; +}; diff --git a/packages/shared/hooks/useUploadPhoto.ts b/packages/shared/hooks/usePhotosUpload.ts similarity index 50% rename from packages/shared/hooks/useUploadPhoto.ts rename to packages/shared/hooks/usePhotosUpload.ts index 4f68817b..84e931f0 100644 --- a/packages/shared/hooks/useUploadPhoto.ts +++ b/packages/shared/hooks/usePhotosUpload.ts @@ -3,6 +3,7 @@ import { useState } from 'react'; import { uploadImage } from '../apis/common/Image'; import { Photo } from '../components/EditPhotoList'; +import { resizeImageFile } from '../utils/image'; type ImageFile = { id: string; image: File }; @@ -23,45 +24,33 @@ const getRandomId = (): string => { return String(Math.random()).slice(2); }; -const getLocalImageUrls = (imageFiles: ImageFile[]): Promise => { - const getLocalImageUrl = imageFiles.map(({ id, image }) => { - const reader = new FileReader(); - - return new Promise((resolve) => { - reader.onloadend = () => { - const imageFileUrl = String(reader.result); - resolve({ id, url: imageFileUrl }); - }; - reader.readAsDataURL(image as File); - }); - }); - - return Promise.all(getLocalImageUrl); -}; - -const getServerImageUrls = (imageFiles: ImageFile[]) => { - const uploadPromises = imageFiles.map(async ({ id, image }) => { - try { - const formData = new FormData(); - formData.append('images', image); - - const { data } = await uploadImage(formData); - const [imageUrl] = data.imageUrls; - - return new Promise((resolve) => { - resolve({ id, url: imageUrl }); - }); - } catch (error) { - return new Promise((resolve) => { - resolve({ id, url: 'upload-failed' }); - }); - } - }); +const getLocalImageUrls = (imageFiles: ImageFile[]): Photo[] => + imageFiles.map(({ id, image }) => ({ id, url: URL.createObjectURL(image) })); + +const getServerImageUrls = async ( + imageFiles: ImageFile[], +): Promise => { + const uploadPromises: Promise[] = imageFiles.map( + async ({ id, image }) => { + try { + const resizedImage = await resizeImageFile(image, 2); + const formData = new FormData(); + formData.append('images', resizedImage); + + const { data } = await uploadImage(formData); + const [imageUrl] = data.imageUrls; + + return { id, url: imageUrl }; + } catch (error) { + return { id, url: 'upload-failed' }; + } + }, + ); return Promise.all(uploadPromises); }; -export const useUploadPhoto = (uploadLimit: number) => { +export const usePhotosUpload = (uploadLimit: number) => { const [photos, setPhotos] = useState([]); const toast = useToast(); @@ -76,7 +65,7 @@ export const useUploadPhoto = (uploadLimit: number) => { setPhotos(newPhotos); }; - const handleUploadPhoto = (files: FileList | null) => { + const handleUploadPhoto = async (files: FileList | null) => { if (!files) { return; } @@ -97,19 +86,19 @@ export const useUploadPhoto = (uploadLimit: number) => { image: file, })); - getLocalImageUrls(imageFiles).then((newPhotos) => { - setPhotos((prevPhotos) => [...newPhotos, ...prevPhotos]); - }); - - getServerImageUrls(imageFiles).then((newPhotos) => { - newPhotos.forEach((newPhoto) => { - setPhotos((prevPhotos) => - prevPhotos.map((photo) => - photo.id === newPhoto.id ? newPhoto : photo, - ), - ); - }); - }); + setPhotos((prevPhotos) => [ + ...getLocalImageUrls(imageFiles), + ...prevPhotos, + ]); + + const newPhotos: Photo[] = await getServerImageUrls(imageFiles); + + setPhotos((prevPhotos) => + prevPhotos.map((photo) => { + const newPhoto = newPhotos.find(({ id }) => photo.id === id); + return newPhoto ? newPhoto : photo; + }), + ); }; const handleDeletePhoto = (photoIndex: number) => { diff --git a/packages/shared/layout/index.tsx b/packages/shared/layout/index.tsx index 3e77a665..3b989e5c 100644 --- a/packages/shared/layout/index.tsx +++ b/packages/shared/layout/index.tsx @@ -1,7 +1,8 @@ import { Box, Container } from '@chakra-ui/react'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { Outlet, useLocation, useNavigate } from 'react-router-dom'; +import Loader from '../components/Loader'; import LocalErrorBoundary from '../components/LocalErrorBoundary'; import useAuthStore from '../store/authStore'; import { AppType } from '../types/app'; @@ -20,6 +21,7 @@ export default function Layout({ appType }: LayoutProps) { const navigate = useNavigate(); const { pathname } = useLocation(); const { setUser } = useAuthStore(); + const [isLoading, setIsLoading] = useState(true); useEffect(() => { try { @@ -36,6 +38,8 @@ export default function Layout({ appType }: LayoutProps) { if (pathname === '/') { navigate('/signin'); } + } finally { + setIsLoading(false); } //TODO 액세스 토큰 갱신 api 정상화 되면 연결 @@ -59,6 +63,10 @@ export default function Layout({ appType }: LayoutProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + if (isLoading) { + return ; + } + return (
diff --git a/packages/shared/package.json b/packages/shared/package.json index a17be1ab..4ef04bce 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -17,6 +17,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.11", + "react-image-file-resizer": "^0.4.8", "react-router-dom": "^6.17.0", "zod": "^3.22.4", "zustand": "^4.4.4" diff --git a/packages/shared/utils/image.ts b/packages/shared/utils/image.ts new file mode 100644 index 00000000..2fbeb8ef --- /dev/null +++ b/packages/shared/utils/image.ts @@ -0,0 +1,28 @@ +import Resizer from 'react-image-file-resizer'; + +const resize = (imageFile: File): Promise => { + return new Promise((resolve) => { + Resizer.imageFileResizer( + imageFile, + 1000, + 1000, + 'WEBP', + 100, + 0, + (uri) => resolve(uri as File), + 'file', + ); + }); +}; + +export const resizeImageFile = async ( + imageFile: File, + maxSizeMB: number, +): Promise => { + if (imageFile.size <= maxSizeMB * 1024 ** 2) { + return imageFile; + } + + const resizedImageFile = await resize(imageFile); + return resizeImageFile(resizedImageFile, maxSizeMB); +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0be1c44..6cdefb1a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -281,6 +281,9 @@ importers: react-error-boundary: specifier: ^4.0.11 version: 4.0.11(react@18.2.0) + react-image-file-resizer: + specifier: ^0.4.8 + version: 0.4.8 react-router-dom: specifier: ^6.17.0 version: 6.18.0(react-dom@18.2.0)(react@18.2.0) @@ -5584,6 +5587,10 @@ packages: react: 18.2.0 dev: false + /react-image-file-resizer@0.4.8: + resolution: {integrity: sha512-Ue7CfKnSlsfJ//SKzxNMz8avDgDSpWQDOnTKOp/GNRFJv4dO9L5YGHNEnj40peWkXXAK2OK0eRIoXhOYpUzUTQ==} + dev: false + /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} dev: false