diff --git a/package-lock.json b/package-lock.json index 3c5fd308..7d438d55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "framer-motion": "^10.16.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-easy-crop": "^5.0.4", "react-helmet-async": "^1.3.0", "react-router-dom": "^6.14.2", "react-slick": "^0.29.0", @@ -6675,6 +6676,11 @@ "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", "dev": true }, + "node_modules/normalize-wheel": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz", + "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==" + }, "node_modules/npm-run-path": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", @@ -7315,6 +7321,24 @@ "react": ">= 16.8 || 18.0.0" } }, + "node_modules/react-easy-crop": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.0.4.tgz", + "integrity": "sha512-JfzSk4cBHoksgAtgWUHR/jDYretebMxS0rpAlltP1LeELGMj4WTa420m4PsYFpgQXoJZV0DXmINUlBWAoAD/PQ==", + "dependencies": { + "normalize-wheel": "^1.0.1", + "tslib": "2.0.1" + }, + "peerDependencies": { + "react": ">=16.4.0", + "react-dom": ">=16.4.0" + } + }, + "node_modules/react-easy-crop/node_modules/tslib": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.1.tgz", + "integrity": "sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ==" + }, "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", diff --git a/package.json b/package.json index 48648c40..4cd6bb16 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "framer-motion": "^10.16.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-easy-crop": "^5.0.4", "react-helmet-async": "^1.3.0", "react-router-dom": "^6.14.2", "react-slick": "^0.29.0", diff --git a/src/features/users/api/profile.ts b/src/features/users/api/profile.ts index 5d79ca6a..7203fcf9 100644 --- a/src/features/users/api/profile.ts +++ b/src/features/users/api/profile.ts @@ -52,7 +52,24 @@ export default class ProfileApi { return await get(`/members/${memberId}/${menu}/count`); } - async updateProfile(form: ProfileEditFormData) { + async updateProfile( + form: ProfileEditFormData, + backgroundImage: File | undefined, + thumbnailImage: File | undefined, + ) { + const data = new FormData(); + + for (const [key, value] of Object.entries(form)) { + data.append(key, value); + } + + //TODO: 서버에서 이미지 업로드 구현 시, 주석 해제 + if (backgroundImage && thumbnailImage) { + console.log(""); + } + // if (backgroundImage) data.append("backgroundImage", backgroundImage); + // if (thumbnailImage) data.append("thumbnail", thumbnailImage); + return await patch("/members", form); } diff --git a/src/features/users/components/ProfileImageSection/AvatarEditButton.style.ts b/src/features/users/components/ProfileImageSection/AvatarEditButton.style.ts deleted file mode 100644 index 02564763..00000000 --- a/src/features/users/components/ProfileImageSection/AvatarEditButton.style.ts +++ /dev/null @@ -1,21 +0,0 @@ -import styled from "@emotion/styled"; -import { PlusCircle } from "@phosphor-icons/react"; - -export const AvatarEditButtonContainer = styled.div` - position: absolute; - top: 0; - left: 0; - opacity: 0.7; - width: 100%; - height: 100%; - border-radius: 50%; - background-color: ${({ theme }) => theme.colors.neutral[100]}; - cursor: pointer; -`; - -export const PlusCircleIcon = styled(PlusCircle)` - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); -`; diff --git a/src/features/users/components/ProfileImageSection/AvatarEditButton.tsx b/src/features/users/components/ProfileImageSection/AvatarEditButton.tsx deleted file mode 100644 index d5601b16..00000000 --- a/src/features/users/components/ProfileImageSection/AvatarEditButton.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { - AvatarEditButtonContainer, - PlusCircleIcon, -} from "./AvatarEditButton.style"; - -export default function AvatarEditButton() { - return ( - - - - ); -} diff --git a/src/features/users/components/ProfileImageSection/ImageEditButton.style.ts b/src/features/users/components/ProfileImageSection/ImageEditButton.style.ts new file mode 100644 index 00000000..6ccb1858 --- /dev/null +++ b/src/features/users/components/ProfileImageSection/ImageEditButton.style.ts @@ -0,0 +1,39 @@ +import { css } from "@emotion/react"; +import styled from "@emotion/styled"; +import { PlusCircle } from "@phosphor-icons/react"; + +export const ImageEditButtonContainer = styled.div<{ + borderRadius: string; + height: string | number; +}>` + position: absolute; + top: 0; + left: 0; + opacity: 0.7; + width: 100%; + height: ${({ height }) => (height === "100%" ? "100%" : `${height}px`)}; + background-color: ${({ theme }) => theme.colors.neutral[100]}; + cursor: pointer; + + ${({ borderRadius }) => + borderRadius === "50%" && + css` + border-radius: 50%; + `} +`; + +export const PlusCircleIcon = styled(PlusCircle)` + position: absolute; + top: 50%; + left: 50%; + color: ${({ theme }) => theme.colors.neutral["05"]}; + transform: translate(-50%, -50%); + transition: + transform linear 0.2s, + color linear 0.2s; + + &.resetIcon { + color: ${({ theme }) => theme.colors.warn["50"]}; + transform: translate(-50%, -50%) rotate(45deg); + } +`; diff --git a/src/features/users/components/ProfileImageSection/ImageEditButton.tsx b/src/features/users/components/ProfileImageSection/ImageEditButton.tsx new file mode 100644 index 00000000..9c7cb6b4 --- /dev/null +++ b/src/features/users/components/ProfileImageSection/ImageEditButton.tsx @@ -0,0 +1,42 @@ +import { StrictPropsWithChildren } from "@/types"; + +import { + ImageEditButtonContainer, + PlusCircleIcon, +} from "./ImageEditButton.style"; + +interface ImageEditButtonProps { + croppedImage: string | null; + borderRadius?: "50%" | "none"; + /** default height: 100%, number는 px 단위 */ + height?: "100%" | number; + onClick: () => void; + onReset: () => void; +} + +export default function ImageEditButton({ + croppedImage, + borderRadius = "none", + height = "100%", + onClick, + onReset, + children, +}: StrictPropsWithChildren) { + /* crop된 이미지가 있으면 이미지 제거, 없으면 이미지 등록 */ + const handleClick = croppedImage ? onReset : onClick; + + return ( + + + + {children} + + ); +} diff --git a/src/features/users/components/ProfileImageSection/ProfileArtEditButton.style.ts b/src/features/users/components/ProfileImageSection/ProfileArtEditButton.style.ts deleted file mode 100644 index d28ba6f0..00000000 --- a/src/features/users/components/ProfileImageSection/ProfileArtEditButton.style.ts +++ /dev/null @@ -1,20 +0,0 @@ -import styled from "@emotion/styled"; -import { PlusCircle } from "@phosphor-icons/react"; - -export const ArtEditButtonContainer = styled.div` - position: absolute; - top: 0; - left: 0; - opacity: 0.7; - width: 100%; - height: 160px; - background-color: ${({ theme }) => theme.colors.neutral[100]}; - cursor: pointer; -`; - -export const PlusCircleIcon = styled(PlusCircle)` - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); -`; diff --git a/src/features/users/components/ProfileImageSection/ProfileArtEditButton.tsx b/src/features/users/components/ProfileImageSection/ProfileArtEditButton.tsx deleted file mode 100644 index 3363012d..00000000 --- a/src/features/users/components/ProfileImageSection/ProfileArtEditButton.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { - ArtEditButtonContainer, - PlusCircleIcon, -} from "./ProfileArtEditButton.style"; - -export default function ArtEditButton() { - return ( - - - - ); -} diff --git a/src/features/users/components/ProfileImageSection/ProfileAvatar.tsx b/src/features/users/components/ProfileImageSection/ProfileAvatar.tsx index 0dd7ef1b..ae504616 100644 --- a/src/features/users/components/ProfileImageSection/ProfileAvatar.tsx +++ b/src/features/users/components/ProfileImageSection/ProfileAvatar.tsx @@ -1,7 +1,6 @@ import Avatar from "@/components/Avatar"; import { StrictPropsWithChildren } from "@/types"; -import AvatarEditButton from "./AvatarEditButton"; import { ProfileAvatarContainer } from "./ProfileAvatar.style"; export default function ProfileAvatar({ children }: StrictPropsWithChildren) { @@ -9,4 +8,3 @@ export default function ProfileAvatar({ children }: StrictPropsWithChildren) { } ProfileAvatar.Avatar = Avatar; -ProfileAvatar.AvatarEditButton = AvatarEditButton; diff --git a/src/features/users/components/ProfileImageSection/index.tsx b/src/features/users/components/ProfileImageSection/index.tsx index 05b21edf..78b6d8f2 100644 --- a/src/features/users/components/ProfileImageSection/index.tsx +++ b/src/features/users/components/ProfileImageSection/index.tsx @@ -1,7 +1,6 @@ import { StrictPropsWithChildren } from "@/types"; import ProfileArt from "./ProfileArt"; -import ArtEditButton from "./ProfileArtEditButton"; import ProfileAvatar from "./ProfileAvatar"; import ProfileSetupButton from "./ProfileSetupButton"; import { ProfileImageSectionContainer } from "./style"; @@ -15,7 +14,5 @@ export default function ProfileImageSection({ } ProfileImageSection.Art = ProfileArt; // 프로필 배경 -ProfileImageSection.ArtEditButton = ArtEditButton; // 프로필 배경 수정 버튼 ProfileImageSection.ProfileSetupButton = ProfileSetupButton; // 프로필 설정 버튼 - ProfileImageSection.ProfileAvatar = ProfileAvatar; // 프로필 아바타 diff --git a/src/features/users/hooks/useCropModal.tsx b/src/features/users/hooks/useCropModal.tsx new file mode 100644 index 00000000..7d2b698f --- /dev/null +++ b/src/features/users/hooks/useCropModal.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; + +import useToast from "@/components/Toast/useToast"; +import { readFile } from "@/libs/imageCrop"; + +const allowedExtensions = ["jpg", "jpeg", "png", "webp"]; + +export default function useCropModal( + inputRef: React.RefObject, +) { + const [isImageCropModal, setIsImageCropModal] = useState(false); + const [imageSrc, setImageSrc] = useState(null); // 사용자의 원본 이미지 + const toast = useToast(); + + const handleImageEditClick = () => { + setIsImageCropModal(true); + if (!imageSrc) inputRef.current?.click(); // 이미지 input open + }; + + const closeImageCropModal = () => { + setIsImageCropModal(false); + resetUploadedImage(); + }; + + /** input file change */ + const handleImageChange = async (e: React.ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + const file = e.target.files[0]; + const fileExtension = file.name.split(".").at(-1)?.toLowerCase(); + + if (!allowedExtensions.includes(fileExtension ?? "")) { + toast.error({ + message: `'jpg' 'jpeg' 'png' 'webp' 확장자 이미지를 등록해주세요.`, + duration: 4, + }); + e.target.value = ""; + return; + } + + const imageDataUrl = await readFile(file); + setImageSrc(imageDataUrl); + } + }; + + /** + * 이미지 crop을 취소하거나 완료하면 + * input과 원본 이미지 상태 초기화 + */ + const resetUploadedImage = () => { + if (inputRef.current) { + inputRef.current.value = ""; + setImageSrc(null); + } + }; + + return { + imageSrc, + isImageCropModal, + handleImageEditClick, + handleImageChange, + closeImageCropModal, + resetUploadedImage, + }; +} diff --git a/src/features/users/hooks/useEditForm.tsx b/src/features/users/hooks/useEditForm.tsx index 2220ca8e..b90104f7 100644 --- a/src/features/users/hooks/useEditForm.tsx +++ b/src/features/users/hooks/useEditForm.tsx @@ -1,12 +1,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { AxiosError } from "axios"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import useToast from "@/components/Toast/useToast"; import useAuth from "@/features/auth/hooks/useAuth"; import { useApi } from "@/hooks/useApi"; import useDebounce from "@/hooks/useDebounce"; +import { fileToWebPFile } from "@/libs/compressor"; import { useCommonToastError } from "@/libs/error"; export interface ProfileEditFormData { @@ -14,21 +15,45 @@ export interface ProfileEditFormData { description: string; } +interface UpdateProfileMutateVariables { + backgroundImage: File | undefined; + thumbnailImage: File | undefined; +} + export default function useEditForm(name: string, description: string) { const { profile } = useApi(); const { fetchUser, user } = useAuth(); const navigate = useNavigate(); + const [croppedArtImage, setCroppedArtImage] = useState(null); // crop 배경 이미지 + const [croppedThumbnailImage, setCroppedThumbnailImage] = + useState(null); // crop 썸네일 이미지 const [form, setForm] = useState({ name: name.length <= 10 ? name : "", description: description, }); const [status, setStatus] = useState({ isWarn: false, message: "" }); const [isFormChange, setIsFormChange] = useState(false); - const updateProfile = useMutation(() => profile.updateProfile(form)); + const [isLoading, setIsLoading] = useState(false); // submit loading 상태 + + const updateProfile = useMutation( + ({ backgroundImage, thumbnailImage }: UpdateProfileMutateVariables) => + profile.updateProfile(form, backgroundImage, thumbnailImage), + ); const queryClient = useQueryClient(); + const toast = useToast(); const { toastAuthError, toastDefaultError } = useCommonToastError(); + const previewArt = useMemo( + () => croppedArtImage && URL.createObjectURL(croppedArtImage), + [croppedArtImage], + ); + + const previewThumbNail = useMemo( + () => croppedThumbnailImage && URL.createObjectURL(croppedThumbnailImage), + [croppedThumbnailImage], + ); + const handleInputChange = ( e: React.ChangeEvent, ) => { @@ -43,7 +68,8 @@ export default function useEditForm(name: string, description: string) { const handleFormSumbit = useDebounce(async (e: React.FormEvent) => { e.preventDefault(); - if (!isFormChange) return; + if (!isFormChange || !user) return; + setIsLoading(true); if (!isNicknameRegexCheck(form.name)) { setStatus({ @@ -55,38 +81,66 @@ export default function useEditForm(name: string, description: string) { return; } - updateProfile.mutate(undefined, { - onSuccess: async () => { - await queryClient.invalidateQueries(["profile", user?.name]); - await fetchUser(); - queryClient.removeQueries(["profile", "edit", user?.name]); - navigate("/profile"); - }, - onError: (error) => { - if (error instanceof AxiosError && error.response?.status) { - const status = error.response.status; - switch (status) { - case 401: // 인증 오류 - toastAuthError(); - break; - case 400: // 정규식 검사 오류 - toast.error({ message: "사용할 수 없는 닉네임입니다." }); - break; - case 409: // 닉네임 중복 오류 - toast.error({ message: "이미 사용중인 닉네임입니다." }); - break; - default: - toastDefaultError(); - break; + let backgroundImage, thumbnailImage; + if (croppedArtImage) { + backgroundImage = await fileToWebPFile(croppedArtImage); + } + if (croppedThumbnailImage) { + thumbnailImage = await fileToWebPFile(croppedThumbnailImage); + } + + updateProfile.mutate( + { backgroundImage, thumbnailImage }, + { + onSuccess: async () => { + await queryClient.invalidateQueries(["profile", user?.name]); + await fetchUser(); + queryClient.removeQueries(["profile", "edit", user?.name]); + navigate("/profile"); + }, + onError: (error) => { + if (error instanceof AxiosError && error.response?.status) { + const status = error.response.status; + switch (status) { + case 401: // 인증 오류 + toastAuthError(); + break; + case 400: // 정규식 검사 오류 + toast.error({ message: "사용할 수 없는 닉네임입니다." }); + break; + case 409: // 닉네임 중복 오류 + toast.error({ message: "이미 사용중인 닉네임입니다." }); + break; + default: + toastDefaultError(); + break; + } } - } + }, + onSettled: () => setIsLoading(false), }, - }); + ); setStatus({ isWarn: false, message: "" }); }, 200); - return { form, status, isFormChange, handleInputChange, handleFormSumbit }; + /** 배경 이미지 및 썸네일 이미지 등록 시, 저장 버튼 활성화 */ + useEffect(() => { + if (croppedArtImage || croppedThumbnailImage) setIsFormChange(true); + }, [croppedArtImage, croppedThumbnailImage]); + + return { + form, + status, + isFormChange, + isLoading, + previewArt, + previewThumbNail, + handleInputChange, + handleFormSumbit, + setCroppedArtImage, + setCroppedThumbnailImage, + }; } function isNicknameRegexCheck(nickname: string) { diff --git a/src/features/users/routes/Edit/EditForm/ImageCropModal.style.ts b/src/features/users/routes/Edit/EditForm/ImageCropModal.style.ts new file mode 100644 index 00000000..47a83b20 --- /dev/null +++ b/src/features/users/routes/Edit/EditForm/ImageCropModal.style.ts @@ -0,0 +1,26 @@ +import styled from "@emotion/styled"; + +export const ContentContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + margin: 0 -8px; +`; + +export const CropperContainer = styled.div` + position: relative; + width: 100%; + height: 50vh; + max-height: 400px; + border-radius: 4px; + overflow: hidden; +`; + +export const RangeInput = styled.input` + margin: 16px 0 4px; +`; + +export const Information = styled.span` + ${({ theme }) => theme.typo["body-2-r"]} + color: ${({ theme }) => theme.colors.warn[60]}; +`; diff --git a/src/features/users/routes/Edit/EditForm/ImageCropModal.tsx b/src/features/users/routes/Edit/EditForm/ImageCropModal.tsx new file mode 100644 index 00000000..45dab64d --- /dev/null +++ b/src/features/users/routes/Edit/EditForm/ImageCropModal.tsx @@ -0,0 +1,112 @@ +import { useState } from "react"; +import Cropper, { Area } from "react-easy-crop"; + +import Button from "@/components/Button"; +import Modal from "@/components/Modal"; +import { dataURLtoFile, getCroppedImg } from "@/libs/imageCrop"; + +import { + ContentContainer, + CropperContainer, + Information, + RangeInput, +} from "./ImageCropModal.style"; + +interface ImageCropModalProps { + imageSrc: string; + aspectWidth: number; // 이미지 가로 비율 + aspectHeight: number; // 이미지 세로 비율 + filename: string; + cropShape?: "rect" | "round"; // crop 사각형 또는 원 + /** file input과 원본 이미지 상태를 초기화 */ + resetImage: () => void; + onClose: () => void; + onSaveCroppedImage: (file: File) => void; +} + +export default function ImageCropModal({ + imageSrc, + aspectWidth, + aspectHeight, + filename, + cropShape = "rect", + resetImage, + onClose, + onSaveCroppedImage, +}: ImageCropModalProps) { + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [croppedAreaPixels, setCroppedAreaPixels] = useState(); + + const onCropComplete = (_: Area, croppedAreaPixels: Area) => { + setCroppedAreaPixels(croppedAreaPixels); + }; + + const saveCroppedImage = async () => { + try { + if (!imageSrc || !croppedAreaPixels) return; + + const croppedImage = await getCroppedImg(imageSrc, croppedAreaPixels); + if (croppedImage) { + const file = dataURLtoFile(croppedImage, `${filename}.jpeg`); + onSaveCroppedImage(file); + } + } catch (e) { + console.error(e); + } finally { + resetImage(); // file input과 원본 이미지 상태를 초기화 + } + }; + + return ( + + + + + + + ) => + setZoom(Number(e.target.value)) + } + /> + 이미지 저장은 아직 지원되지 않습니다. + + + + + 취소 + + + 완료 + + + + ); +} diff --git a/src/features/users/routes/Edit/EditForm/index.tsx b/src/features/users/routes/Edit/EditForm/index.tsx index c3151b17..49132776 100644 --- a/src/features/users/routes/Edit/EditForm/index.tsx +++ b/src/features/users/routes/Edit/EditForm/index.tsx @@ -1,50 +1,149 @@ +import { useRef } from "react"; import { useNavigate } from "react-router-dom"; import Button from "@/components/Button"; import Textarea from "@/components/TextArea"; import TextInput from "@/components/TextInput"; +import ProfileImageSection from "@/features/users/components/ProfileImageSection"; +import ImageEditButton from "@/features/users/components/ProfileImageSection/ImageEditButton"; +import ProfileAvatar from "@/features/users/components/ProfileImageSection/ProfileAvatar"; +import useCropModal from "@/features/users/hooks/useCropModal"; import useEditForm from "@/features/users/hooks/useEditForm"; -import { ButtonContainer, EditFormContainer, Form, Title } from "./style"; +import ImageCropModal from "./ImageCropModal"; +import { + FileInput, + ButtonContainer, + EditFormContainer, + Form, + InputSection, + Title, +} from "./style"; interface EditFromProps { name: string; description: string; + thumbnail: string; + backgroundImage: string; } -export default function EditForm({ name, description }: EditFromProps) { - const { form, status, isFormChange, handleInputChange, handleFormSumbit } = - useEditForm(name, description); +export default function EditForm({ + name, + description, + thumbnail, + backgroundImage, +}: EditFromProps) { + const { + form, + status, + isFormChange, + isLoading, + previewArt, + previewThumbNail, + handleInputChange, + handleFormSumbit, + setCroppedArtImage, + setCroppedThumbnailImage, + } = useEditForm(name, description); + const navigate = useNavigate(); + const artRef = useRef(null); // 배경 이미지 input + const thumbnailRef = useRef(null); // 썸네일 이미지 input + + const { + imageSrc: artImageSrc, + isImageCropModal: isArtCropModal, + handleImageEditClick: handleArtEditClick, + handleImageChange: handleArtImageChange, + closeImageCropModal: closeArtCropModal, + resetUploadedImage: resetUploadedArtImage, + } = useCropModal(artRef); + + const { + imageSrc: thumbnailImageSrc, + isImageCropModal: isThumbnailCropModal, + handleImageEditClick: handleThumbnailEditClick, + handleImageChange: handleThumbnailImageChange, + closeImageCropModal: closeThumbnailCropModal, + resetUploadedImage: resetUploadedThumbnailImage, + } = useCropModal(thumbnailRef); + return ( - - 닉네임 - handleInputChange(e)} + + - - - 자기소개 - handleInputChange(e)} - /> - + { + resetUploadedArtImage(); + setCroppedArtImage(null); + }} + > + + + + + { + resetUploadedThumbnailImage(); + setCroppedThumbnailImage(null); + }} + > + + + + + + + + 닉네임 + handleInputChange(e)} + /> + + + 자기소개 + handleInputChange(e)} + /> + + + + {/* 배경 이미지 crop 모달 */} + {isArtCropModal && artImageSrc && ( + setCroppedArtImage(file)} + /> + )} + + {/* 썸네일 이미지 crop 모달 */} + {isThumbnailCropModal && thumbnailImageSrc && ( + setCroppedThumbnailImage(file)} + /> + )} + {isLoading && 로딩중} ); } diff --git a/src/features/users/routes/Edit/EditForm/style.ts b/src/features/users/routes/Edit/EditForm/style.ts index a1cbf685..ccc6efbe 100644 --- a/src/features/users/routes/Edit/EditForm/style.ts +++ b/src/features/users/routes/Edit/EditForm/style.ts @@ -6,24 +6,23 @@ interface TitleProps { } export const EditFormContainer = styled.div` - --profile-art-height: 160px; // 160px - --bottom-navigation-height: 74px; // 66px + 8px - --margin-top: 60px; display: flex; flex-direction: column; - justify-content: space-between; - min-height: calc( - 100vh - var(--profile-art-height) - var(--bottom-navigation-height) - - var(--margin-top) - ); - padding: 0px 16px; - margin-top: var(--margin-top); `; export const Form = styled.form` + --profile-art-height: 160px; + --input-section-margin-bottom: 16px; + display: flex; flex-direction: column; - gap: 24px; + min-height: calc( + 100vh - var(--profile-art-height) - var(--input-section-margin-bottom) + ); +`; + +export const FileInput = styled.input` + display: none; `; export const Title = styled.span` @@ -42,8 +41,19 @@ export const Title = styled.span` `} `; +export const InputSection = styled.div` + --margin-top: 60px; + + display: flex; + flex-direction: column; + gap: 24px; + padding: 0px 16px; + margin: var(--margin-top) 0 var(--input-section-margin-bottom); +`; + export const ButtonContainer = styled.div` display: flex; flex-direction: column; gap: 8px; + padding: 0px 16px; `; diff --git a/src/features/users/routes/Edit/index.tsx b/src/features/users/routes/Edit/index.tsx index c2511d50..d810c087 100644 --- a/src/features/users/routes/Edit/index.tsx +++ b/src/features/users/routes/Edit/index.tsx @@ -4,9 +4,6 @@ import Head from "@/components/Head"; import useAuth from "@/features/auth/hooks/useAuth"; import { useApi } from "@/hooks/useApi"; -import ProfileImageSection from "../../components/ProfileImageSection"; -import ProfileAvatar from "../../components/ProfileImageSection/ProfileAvatar"; - import EditForm from "./EditForm"; import ProfileEditLoading from "./ProfileEditLoading"; import { ProfileEditContainer } from "./style"; @@ -25,23 +22,7 @@ export default function ProfileEdit() { {userProfile && ( - - - - - - - - - - + )} > diff --git a/src/libs/imageCrop/index.ts b/src/libs/imageCrop/index.ts new file mode 100644 index 00000000..a9a6c857 --- /dev/null +++ b/src/libs/imageCrop/index.ts @@ -0,0 +1,99 @@ +import { Area } from "react-easy-crop"; + +/** + * 이미지를 crop하기 위해, 업로드 한 이미지를 읽어 화면에 보여줍니다. + */ +export function readFile(file: Blob): Promise { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.addEventListener( + "load", + () => resolve(reader.result as string), + false, + ); + reader.readAsDataURL(file); + }); +} + +/** + * @see {@link https://github.com/ValentinH/react-easy-crop/blob/main/docs/src/components/Demo/cropImage.ts} + * @desc 이미지 crop 결과를 생성 + * @retrun base64 string 또는 blob + */ +export async function getCroppedImg( + imageSrc: string, + pixelCrop: Area, +): Promise { + const image = await createImage(imageSrc); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + if (!ctx) { + return null; + } + + // set canvas size to match the bounding box + canvas.width = image.width; + canvas.height = image.height; + + // draw image + ctx.drawImage(image, 0, 0); + + // croppedAreaPixels values are bounding box relative + // extract the cropped image using these values + const data = ctx.getImageData( + pixelCrop.x, + pixelCrop.y, + pixelCrop.width, + pixelCrop.height, + ); + + // set canvas width to final desired crop size - this will clear existing context + canvas.width = pixelCrop.width; + canvas.height = pixelCrop.height; + + // paste generated rotate image at the top left corner + ctx.putImageData(data, 0, 0); + + // As Base64 string + return canvas.toDataURL("image/jpeg"); + + // As a blob + // return new Promise((resolve) => { + // croppedCanvas.toBlob((file) => { + // if (file) resolve(URL.createObjectURL(file)); + // }, "image/jpeg"); + // }); +} + +/** + * base64 image data를 File 객체로 변환 + */ +export function dataURLtoFile(dataurl: string, fileName: string) { + const arr = dataurl.split(","); + const bstr = atob(arr[1]); + let n = bstr.length; + const u8arr = new Uint8Array(n); + const mimeMatch = arr[0].match(/:(.*?);/); + let mime; // file type + + if (mimeMatch) mime = mimeMatch[1]; + + while (n--) { + u8arr[n] = bstr.charCodeAt(n); + } + + return new File([u8arr], fileName, { type: mime ?? "image/jpeg" }); +} + +/** + * 업로드한 이미지의 element 생성 + */ +const createImage = (url: string): Promise => + new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener("load", () => resolve(image)); + image.addEventListener("error", (error) => reject(error)); + image.setAttribute("crossOrigin", "anonymous"); // needed to avoid cross-origin issues on CodeSandbox + image.src = url; + });