From 3139c4d5484620278232a7e2b162612ffde2f0c3 Mon Sep 17 00:00:00 2001 From: Chiman2937 Date: Sun, 21 Dec 2025 13:17:05 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20nickname,=20message,=20mbti=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EC=97=90=20Hint=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../profile-edit-fields/mbti-field/index.tsx | 22 ++++++++++++++----- .../message-field/index.tsx | 9 ++++---- .../nickname-field/index.tsx | 5 ++++- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/components/pages/user/profile/profile-edit-fields/mbti-field/index.tsx b/src/components/pages/user/profile/profile-edit-fields/mbti-field/index.tsx index fbbc6f14..51f63736 100644 --- a/src/components/pages/user/profile/profile-edit-fields/mbti-field/index.tsx +++ b/src/components/pages/user/profile/profile-edit-fields/mbti-field/index.tsx @@ -1,26 +1,36 @@ import { AnyFieldApi } from '@tanstack/react-form'; -import { Input, Label } from '@/components/ui'; +import { Hint, Input, Label } from '@/components/ui'; interface Props { field: AnyFieldApi; } export const MBTIField = ({ field }: Props) => { + const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; return (
- + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + onChange={(e) => { + field.handleChange(e.target.value); + field.setMeta((prev) => ({ + ...prev, + errorMap: { + ...prev.errorMap, + onBlur: undefined, + }, + })); + }} /> + {isInvalid && }
); }; diff --git a/src/components/pages/user/profile/profile-edit-fields/message-field/index.tsx b/src/components/pages/user/profile/profile-edit-fields/message-field/index.tsx index 683045de..36ab87f0 100644 --- a/src/components/pages/user/profile/profile-edit-fields/message-field/index.tsx +++ b/src/components/pages/user/profile/profile-edit-fields/message-field/index.tsx @@ -1,26 +1,25 @@ import { AnyFieldApi } from '@tanstack/react-form'; -import { Input, Label } from '@/components/ui'; +import { Hint, Input, Label } from '@/components/ui'; interface Props { field: AnyFieldApi; } export const MessageField = ({ field }: Props) => { + const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; return (
- + field.handleChange(e.target.value)} /> + {isInvalid && }
); }; diff --git a/src/components/pages/user/profile/profile-edit-fields/nickname-field/index.tsx b/src/components/pages/user/profile/profile-edit-fields/nickname-field/index.tsx index c1b8d326..442c2139 100644 --- a/src/components/pages/user/profile/profile-edit-fields/nickname-field/index.tsx +++ b/src/components/pages/user/profile/profile-edit-fields/nickname-field/index.tsx @@ -1,12 +1,14 @@ import { AnyFieldApi } from '@tanstack/react-form'; -import { Input, Label } from '@/components/ui'; +import { Hint, Input, Label } from '@/components/ui'; interface Props { field: AnyFieldApi; } export const NickNameField = ({ field }: Props) => { + const isInvalid = !field.state.meta.isValid; + return (
); }; From 9f3221c4f60e4cbc6baffe9f3092a7ee2657c924 Mon Sep 17 00:00:00 2001 From: Chiman2937 Date: Sun, 21 Dec 2025 13:17:47 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20imageinput=20onChange=EC=8B=9C=20va?= =?UTF-8?q?lidation=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ui/imageinput/index.tsx | 14 +++++++++++ src/lib/constants/image.ts | 7 ++++++ src/lib/validateImage.ts | 34 ++++++++++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 src/lib/constants/image.ts create mode 100644 src/lib/validateImage.ts diff --git a/src/components/ui/imageinput/index.tsx b/src/components/ui/imageinput/index.tsx index e2f81b98..7e4c0045 100644 --- a/src/components/ui/imageinput/index.tsx +++ b/src/components/ui/imageinput/index.tsx @@ -1,5 +1,7 @@ import React, { useEffect, useRef, useState } from 'react'; +import { validateImage } from '@/lib/validateImage'; + export type ImageRecord = Record; export interface ImageInputProps { @@ -103,6 +105,18 @@ export const ImageInput = ({ const handleFileChange = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); + + for (const file of files) { + const validation = validateImage(file); + if (!validation.valid) { + // toast.error(validation.error); + alert(validation.error); + e.target.value = ''; + return; + } + } + + // 검증통과 하면 이미지 추가 addImages(files); e.target.value = ''; }; diff --git a/src/lib/constants/image.ts b/src/lib/constants/image.ts new file mode 100644 index 00000000..ac267a6b --- /dev/null +++ b/src/lib/constants/image.ts @@ -0,0 +1,7 @@ +export const IMAGE_CONFIG = { + maxSizeBytes: 20971520, // 20MB + maxWidth: 2000, + maxHeight: 2000, + allowedTypes: ['image/jpeg', 'image/png', 'image/webp'], + allowedExtensions: ['.jpg', '.jpeg', '.png', '.webp'], +}; diff --git a/src/lib/validateImage.ts b/src/lib/validateImage.ts new file mode 100644 index 00000000..704f7014 --- /dev/null +++ b/src/lib/validateImage.ts @@ -0,0 +1,34 @@ +import { IMAGE_CONFIG } from './constants/image'; + +export const validateImage = (file: File): { valid: boolean; error?: string } => { + // 1. 파일 크기 검증 + if (file.size > IMAGE_CONFIG.maxSizeBytes) { + const currentSizeMB = (file.size / (1024 * 1024)).toFixed(0); + return { + valid: false, + error: `이미지 크기가 너무 큽니다. 최대 20MB까지 가능합니다. \n(현재: ${currentSizeMB}MB)`, + }; + } + + // 2. Content Type 검증 + if (!IMAGE_CONFIG.allowedTypes.includes(file.type)) { + const currentFileType = file.type.split('/')[1]; + return { + valid: false, + error: `${IMAGE_CONFIG.allowedExtensions.join(', ')} 형식만 업로드 가능합니다. \n(현재: ${currentFileType})`, + }; + } + + // 3. 확장자 검증 + const fileName = file.name.toLowerCase(); + const hasValidExtension = IMAGE_CONFIG.allowedExtensions.some((ext) => fileName.endsWith(ext)); + + if (!hasValidExtension) { + return { + valid: false, + error: `파일 확장자가 올바르지 않습니다. \n(${IMAGE_CONFIG.allowedExtensions.join(', ')}만 가능)`, + }; + } + + return { valid: true }; +}; From ba30ecc7a4ea7ba33a9757a16e5ca7150f93ce3b Mon Sep 17 00:00:00 2001 From: Chiman2937 Date: Sun, 21 Dec 2025 13:18:39 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20mypage=20edit=20-=20validation=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/profile/profile-edit-modal/index.tsx | 90 +++++++++++++------ src/lib/schema/mypage.ts | 36 ++++++++ 2 files changed, 100 insertions(+), 26 deletions(-) create mode 100644 src/lib/schema/mypage.ts diff --git a/src/components/pages/user/profile/profile-edit-modal/index.tsx b/src/components/pages/user/profile/profile-edit-modal/index.tsx index 293415c4..249c5b29 100644 --- a/src/components/pages/user/profile/profile-edit-modal/index.tsx +++ b/src/components/pages/user/profile/profile-edit-modal/index.tsx @@ -1,6 +1,7 @@ 'use client'; import { useForm } from '@tanstack/react-form'; +import { API } from '@/api'; import { Button, ImageRecord, @@ -11,6 +12,13 @@ import { } from '@/components/ui'; import { useUpdateUser } from '@/hooks/use-user'; import { useUserImageUpdate } from '@/hooks/use-user/use-user-image-update'; +import { + mbtiOnBlurSchema, + mbtiOnChangeSchema, + nickNameOnChangeSchema, + profileImageOnChangeSchema, + profileMessageOnChangeSchema, +} from '@/lib/schema/mypage'; import { UpdateMyInfoPayloads, User } from '@/types/service/user'; import { ImageField, MBTIField, MessageField, NickNameField } from '../profile-edit-fields'; @@ -24,16 +32,8 @@ export const ProfileEditModal = ({ user }: Props) => { const { close } = useModal(); - const { - mutateAsync: updateUser, - isPending: isUserInfoPending, - error: _userInfoError, - } = useUpdateUser(); - const { - mutateAsync: updateUserImage, - isPending: isUserImagePending, - error: _userImageError, - } = useUserImageUpdate(); + const { mutateAsync: updateUser, error: userInfoError } = useUpdateUser(); + const { mutateAsync: updateUserImage, error: userImageError } = useUserImageUpdate(); const form = useForm({ defaultValues: { @@ -42,15 +42,30 @@ export const ProfileEditModal = ({ user }: Props) => { profileMessage, mbti, }, + validators: { + onSubmitAsync: async ({ value }) => { + if (value.nickName === nickName) return null; + const res = await API.userService.getNicknameAvailability({ nickName: value.nickName }); + if (!res.available) { + return { + form: '입력값을 확인해주세요', + fields: { + nickName: { message: '이미 사용 중인 닉네임입니다' }, + }, + }; + } + return null; + }, + }, onSubmit: async ({ value }) => { const { profileImage, nickName, profileMessage, mbti } = value; - + const nextMbti = mbti.toUpperCase(); // 프로필 항목 업데이트 조건 체크 const nextProfileInfo: UpdateMyInfoPayloads = { - ...(user.nickName !== value.nickName && { nickName }), - ...(user.profileMessage !== value.profileMessage && { profileMessage }), - ...(user.mbti !== value.mbti && { mbti }), + ...(user.nickName !== nickName && { nickName }), + ...(user.profileMessage !== profileMessage && { profileMessage }), + ...(user.mbti !== nextMbti && { mbti: nextMbti }), }; /* @@ -68,17 +83,20 @@ export const ProfileEditModal = ({ user }: Props) => { await updateUserImage({ file: imageFileObject }); } close(); - } catch (error) { + } catch { /* - todo: 이미지 변경과 정보 변경 중 하나라도 실패하면 각 항목에 대한 에러메시지 보여줘야함 + 이미지 변경과 정보 변경 중 하나라도 실패하면 각 항목에 대한 에러메시지 alert + todo: toast로 변경 */ - console.log('요청 실패', error); + const errors = []; + if (userInfoError) errors.push('사용자 정보'); + if (userImageError) errors.push('프로필 이미지'); + + alert(`${errors.join(', ')} 업데이트에 실패했습니다. 다시 시도해주세요`); } }, }); - const isPending = isUserInfoPending || isUserImagePending; - return ( 프로필 수정 @@ -92,17 +110,37 @@ export const ProfileEditModal = ({ user }: Props) => { form.handleSubmit(); }} > - } name='profileImage' /> - } name='nickName' /> - } name='profileMessage' /> - } name='mbti' /> + } + name='profileImage' + /> + } + name='nickName' + /> + } + name='profileMessage' + /> + } + name='mbti' + />
- + [state.canSubmit, state.isSubmitting]}> + {([canSubmit, isSubmitting]) => ( + + )} +
diff --git a/src/lib/schema/mypage.ts b/src/lib/schema/mypage.ts new file mode 100644 index 00000000..c3e245c9 --- /dev/null +++ b/src/lib/schema/mypage.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +export const profileImageOnChangeSchema = z.union([ + z.object({ blobUrl: z.instanceof(File) }), + z.object({ Url: z.null() }), + z.object({}), +]); + +export const nickNameOnChangeSchema = z + .string() + .min(2, '닉네임은 2글자 이상이어야 합니다.') + .max(20, '닉네임은 20글자 이하여야 합니다.'); + +export const profileMessageOnChangeSchema = z + .string() + .max(20, '소개글은 20글자까지 작성 가능합니다.'); + +export const mbtiOnChangeSchema = z.string().refine( + (val) => { + if (val === '') return true; + if (val.length >= 1 && !['I', 'E', 'i', 'e'].includes(val[0])) return false; + if (val.length >= 2 && !['S', 'N', 's', 'n'].includes(val[1])) return false; + if (val.length >= 3 && !['T', 'F', 't', 'f'].includes(val[2])) return false; + if (val.length === 4 && !['J', 'P', 'j', 'p'].includes(val[3])) return false; + return true; + }, + { message: '유효한 MBTI가 아닙니다' }, +); + +export const mbtiOnBlurSchema = z.string().refine( + (val) => { + if (val === '') return true; + return val.length === 4 && /^[IEie][SNsn][TFtf][JPjp]$/.test(val); + }, + { message: 'MBTI 4글자를 모두 입력해주세요' }, +); From 8e7820f41e64e1e1224abff28d5b0b486382c9b9 Mon Sep 17 00:00:00 2001 From: Chiman2937 Date: Sun, 21 Dec 2025 13:43:00 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20-=20=EB=84=88=EB=B9=84,=20=EB=86=92?= =?UTF-8?q?=EC=9D=B4=20=EA=B2=80=EC=A6=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../profile-edit-fields/image-field/index.tsx | 1 - src/components/ui/imageinput/index.tsx | 7 +-- src/lib/validateImage.ts | 52 +++++++++++++------ 3 files changed, 41 insertions(+), 19 deletions(-) diff --git a/src/components/pages/user/profile/profile-edit-fields/image-field/index.tsx b/src/components/pages/user/profile/profile-edit-fields/image-field/index.tsx index b2ae17f0..009363db 100644 --- a/src/components/pages/user/profile/profile-edit-fields/image-field/index.tsx +++ b/src/components/pages/user/profile/profile-edit-fields/image-field/index.tsx @@ -13,7 +13,6 @@ export const ImageField = ({ field, initialImages }: Props) => { return (
; @@ -24,7 +25,7 @@ export const ImageInput = ({ children, onChange, maxFiles = 1, - accept = 'image/*', + accept = IMAGE_CONFIG.allowedTypes.join(','), multiple = false, mode = 'replace', initialImages = [], @@ -103,11 +104,11 @@ export const ImageInput = ({ updateImages(newImages); }; - const handleFileChange = (e: React.ChangeEvent) => { + const handleFileChange = async (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); for (const file of files) { - const validation = validateImage(file); + const validation = await validateImage(file); if (!validation.valid) { // toast.error(validation.error); alert(validation.error); diff --git a/src/lib/validateImage.ts b/src/lib/validateImage.ts index 704f7014..2221a2a1 100644 --- a/src/lib/validateImage.ts +++ b/src/lib/validateImage.ts @@ -1,34 +1,56 @@ import { IMAGE_CONFIG } from './constants/image'; -export const validateImage = (file: File): { valid: boolean; error?: string } => { - // 1. 파일 크기 검증 - if (file.size > IMAGE_CONFIG.maxSizeBytes) { - const currentSizeMB = (file.size / (1024 * 1024)).toFixed(0); +export const validateImage = async (file: File): Promise<{ valid: boolean; error?: string }> => { + // 1. 확장자 검증 + const fileName = file.name.toLowerCase(); + const hasValidExtension = IMAGE_CONFIG.allowedExtensions.some((ext) => fileName.endsWith(ext)); + + if (!hasValidExtension) { return { valid: false, - error: `이미지 크기가 너무 큽니다. 최대 20MB까지 가능합니다. \n(현재: ${currentSizeMB}MB)`, + error: `파일 확장자가 올바르지 않습니다. \n(${IMAGE_CONFIG.allowedExtensions.join(', ')}만 가능)`, }; } - // 2. Content Type 검증 - if (!IMAGE_CONFIG.allowedTypes.includes(file.type)) { - const currentFileType = file.type.split('/')[1]; + // 2. 파일 크기 검증 + if (file.size > IMAGE_CONFIG.maxSizeBytes) { + const currentSizeMB = (file.size / (1024 * 1024)).toFixed(0); return { valid: false, - error: `${IMAGE_CONFIG.allowedExtensions.join(', ')} 형식만 업로드 가능합니다. \n(현재: ${currentFileType})`, + error: `이미지 크기가 너무 큽니다. 최대 20MB까지 가능합니다. \n현재: ${currentSizeMB}MB`, }; } - // 3. 확장자 검증 - const fileName = file.name.toLowerCase(); - const hasValidExtension = IMAGE_CONFIG.allowedExtensions.some((ext) => fileName.endsWith(ext)); - - if (!hasValidExtension) { + // 3. 파일 사이즈 검증 + const { width, height } = await getImageDimensions(file); + if (width > 2000 || height > 2000) { return { valid: false, - error: `파일 확장자가 올바르지 않습니다. \n(${IMAGE_CONFIG.allowedExtensions.join(', ')}만 가능)`, + error: `이미지는 2000x2000 이하여야 합니다. \n현재: 너비(${width}), 높이(${height})`, }; } return { valid: true }; }; + +const getImageDimensions = async (file: File): Promise<{ width: number; height: number }> => { + return new Promise((resolve, reject) => { + const img = new Image(); + const url = URL.createObjectURL(file); + + img.onload = () => { + URL.revokeObjectURL(url); // 메모리 해제 + resolve({ + width: img.width, + height: img.height, + }); + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('이미지 로드 실패')); + }; + + img.src = url; + }); +}; From 88bfa2770f1a93aff57a0d04e7ac4ccc7386a419 Mon Sep 17 00:00:00 2001 From: Chiman2937 Date: Sun, 21 Dec 2025 14:22:02 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20coderabbitai=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../profile-edit-fields/mbti-field/index.tsx | 5 +++-- .../profile-edit-fields/message-field/index.tsx | 5 +++-- .../profile-edit-fields/nickname-field/index.tsx | 6 +++--- .../user/profile/profile-edit-modal/index.tsx | 14 +++----------- src/lib/validateImage.ts | 4 ++-- 5 files changed, 14 insertions(+), 20 deletions(-) diff --git a/src/components/pages/user/profile/profile-edit-fields/mbti-field/index.tsx b/src/components/pages/user/profile/profile-edit-fields/mbti-field/index.tsx index 51f63736..2930679e 100644 --- a/src/components/pages/user/profile/profile-edit-fields/mbti-field/index.tsx +++ b/src/components/pages/user/profile/profile-edit-fields/mbti-field/index.tsx @@ -8,11 +8,12 @@ interface Props { export const MBTIField = ({ field }: Props) => { const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; + const fieldId = 'profile-mbti'; return (
- - + { const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; + const fieldId = 'profile-message'; return (
- - + { const isInvalid = !field.state.meta.isValid; - + const fieldId = 'profile-nickname'; return (
-