1- import React , { useState } from "react" ;
1+ import { useEffect , useRef , useState , type ChangeEvent } from "react" ;
22
33import Image from "next/image" ;
44
5+ const ALLOWED_PROFILE_IMAGE_TYPES = new Set ( [ "image/jpeg" , "image/png" , "image/webp" ] ) ;
6+ const MAX_PROFILE_IMAGE_SIZE = 10 * 1024 * 1024 ;
7+
58interface ProfileAvatarInputProps {
9+ file ?: File | null ;
10+ error ?: string ;
611 onImageChange ?: ( file : File | null ) => void ;
12+ onErrorChange ?: ( message : string | null ) => void ;
13+ }
14+
15+ function validateProfileImage ( file : File ) {
16+ if ( ! ALLOWED_PROFILE_IMAGE_TYPES . has ( file . type ) ) {
17+ return "프로필 이미지는 JPG, PNG, WEBP 형식만 업로드할 수 있습니다." ;
18+ }
19+
20+ if ( file . size > MAX_PROFILE_IMAGE_SIZE ) {
21+ return "프로필 이미지는 10MB 이하만 업로드할 수 있습니다." ;
22+ }
23+
24+ return null ;
725}
826
9- export default function ProfileAvatarInput ( { onImageChange } : ProfileAvatarInputProps ) {
10- const [ imageUrl , setImageUrl ] = useState < string | null > ( null ) ;
27+ export default function ProfileAvatarInput ( {
28+ file = null ,
29+ error,
30+ onImageChange,
31+ onErrorChange,
32+ } : ProfileAvatarInputProps ) {
33+ const [ imageUrl , setImageUrl ] = useState < string | null > ( ( ) =>
34+ file ? URL . createObjectURL ( file ) : null
35+ ) ;
36+ const imageUrlRef = useRef ( imageUrl ) ;
37+
38+ const replaceImageUrl = ( nextImageUrl : string | null ) => {
39+ if ( imageUrlRef . current ) {
40+ URL . revokeObjectURL ( imageUrlRef . current ) ;
41+ }
42+
43+ imageUrlRef . current = nextImageUrl ;
44+ setImageUrl ( nextImageUrl ) ;
45+ } ;
46+
47+ useEffect ( ( ) => {
48+ return ( ) => {
49+ if ( imageUrlRef . current ) {
50+ URL . revokeObjectURL ( imageUrlRef . current ) ;
51+ }
52+ } ;
53+ } , [ ] ) ;
1154
12- const handleFileChange = ( e : React . ChangeEvent < HTMLInputElement > ) => {
55+ const handleFileChange = ( e : ChangeEvent < HTMLInputElement > ) => {
1356 const file = e . target . files ?. [ 0 ] ;
14- if ( file ) {
15- const url = URL . createObjectURL ( file ) ;
16- setImageUrl ( url ) ;
17- onImageChange ?.( file ) ;
57+ e . target . value = "" ;
58+
59+ if ( ! file ) return ;
60+
61+ const validationMessage = validateProfileImage ( file ) ;
62+ if ( validationMessage ) {
63+ replaceImageUrl ( null ) ;
64+ onImageChange ?.( null ) ;
65+ onErrorChange ?.( validationMessage ) ;
66+ return ;
1867 }
68+
69+ replaceImageUrl ( URL . createObjectURL ( file ) ) ;
70+ onErrorChange ?.( null ) ;
71+ onImageChange ?.( file ) ;
1972 } ;
2073
2174 const handleRemove = ( ) => {
22- setImageUrl ( null ) ;
75+ replaceImageUrl ( null ) ;
76+ onErrorChange ?.( null ) ;
2377 onImageChange ?.( null ) ;
2478 } ;
2579
@@ -42,7 +96,12 @@ export default function ProfileAvatarInput({ onImageChange }: ProfileAvatarInput
4296 </ div >
4397
4498 < label className = "absolute inset-0 cursor-pointer" aria-label = "프로필 이미지 변경" >
45- < input type = "file" accept = "image/*" onChange = { handleFileChange } className = "hidden" />
99+ < input
100+ type = "file"
101+ accept = "image/jpeg,image/png,image/webp"
102+ onChange = { handleFileChange }
103+ className = "hidden"
104+ />
46105 </ label >
47106 </ div >
48107
@@ -55,6 +114,12 @@ export default function ProfileAvatarInput({ onImageChange }: ProfileAvatarInput
55114 이미지 제거
56115 </ button >
57116 ) }
117+
118+ { error && (
119+ < p className = "text-caption font-regular text-error-default text-center" role = "alert" >
120+ { error }
121+ </ p >
122+ ) }
58123 </ div >
59124 ) ;
60125}
0 commit comments