본문 바로가기

공부일지/Project

React Project ) Slack Clone, 메인페이지, Header, 프로필 변경, 로그아웃 작업

728x90
반응형

안녕하세요. 저번시간에 이어서 Slack Clone 코딩 두번째 이야기!!

프론트 공부하고 있는 비전공자 에디터 M입니다.

 

저는 원래 퍼블리싱을 하였는데 개발쪽 공부를 하려고 하니 예전에 jsp와 php 배웠을때가 기억이 나네요.

하면 할 수록 이게 맞나 싶고 포기하고 싶기도 한데 그래도 버티고 공부하면 프론트쪽 업무가 편해지는 날이 오겠죠??

 

그런데 찾아보니 There.js라고 웹에 그래픽 작업을 할 수 있는게 있더라구요. 그걸 보고 아... 내가 IT쪽으로 온 이유가 생각나더라구요. 디자인을 하며 다양한 효과를 눈으로 보여주며 변화하는게 재밌고 신기해서 했다는걸 굳이 React나 Vue를 배우거나 하지 않고 There.js쪽으로 가도 괜찮겠다. 

그러기 위해선 WebGL쪽을 공부해야 되겠더라구요. 원리와 구동 방식을 알아야 더 다양하게 공부하고 빠르게 습득 할 수 있을거 같아서요. 그리고 관련 공부에서 블로그에 작성할께요.

 

이제 본론으로 들어와 Slack Clone 의 코드 리뷰를 이어서 해볼께요. 참고로 이미 완성된 코드를 리뷰하는거라 만약 제가 블로그에 작성한 코드 그대로 입력했는데 오류가 생길꺼에요. 

제 완성본 코드를 보시려면 깃헙 주소로 가셔서 보시며 됩니다.

https://github.com/piwe2004/slack-clone

 

GitHub - piwe2004/slack-clone

Contribute to piwe2004/slack-clone development by creating an account on GitHub.

github.com

저번시간에는 로그인까지 코드 리뷰를 했으니 오늘은 로그인후에 나오는 화면과 로그아웃, 채널 기능을 리뷰 해 볼께요.

 

아 그러고 보니 저번시간에 App 팡일을 안보여 드린거 같아요. 그럼 App 파일 코드부터 볼까요?

function App() {
    const dispatch = useDispatch();
    const {isLoading, currentUser} = useSelector((state)=>state.user)
    useEffect(()=>{
        const unsubscribe = onAuthStateChanged(getAuth(), (user)=>{
            if(!!user){
                dispatch(setUser(user))
            }else{
                dispatch(clearUser());
            }
        })
        return () => unsubscribe()
    }, [dispatch])
    if(isLoading){
        return (
            <Stack alignItems='center' justifyContent='center' height='100vh'>
                <CircularProgress color='secondary' size={150} />
            </Stack>
        )
    }
    return (
        <Routes>
            <Route path='/' element={currentUser ? <Main /> : <Navigate to="/login" />} />
            <Route path="/login" element={currentUser ? <Navigate to="/" /> : <Login />} />
            <Route path="/join" element={currentUser ? <Navigate to="/" /> :<Join />} />
        </Routes>
    );
}

store에 유저의 정보가 있냐 없냐에 따라서 보여줄 페이지가 달라요.

유저의 정보가 있으면 Main 페이지로 이동하고 없을때는 Login 페이지로 이동합니다.

route 관련해서는 제 블로그에 따로 설명글을 올려놓은게 있어서 설명은 하지 않을께요.

https://min-webstory.tistory.com/entry/react-router-debounce

 

비전공자의 공부일지 - React Router Dom, useDebounce

오늘은 어제 하던 디즈니플러스 앱을 마무리하려고 한다. 요즘 재밌게 보고 있는 유튜브가 있는데 asmr처럼 말없이 키보드 소리만 들리는 유튜브인데 꽤 재미있고 javascript에 대해 도움도 많이 되

min-webstory.tistory.com

 

그럼 이제 메인페이지쪽을 보자면

메인페이지


Slack Clone 메인페이지

function Main() {
    const {theme} = useSelector(state => state)

    return (
        // TODO backgroundColor 테마 적용
        <Box sx={{display:'flex', backgroundColor:theme.subTheme}}>
            <Header />
            <Drawer variant='permanent' sx={{width:300}} className='no-scroll'>
                <Toolbar />
                <Box sx={{display:'flex', height:'100%', minHeight:'calc( 100vh - 64px )'}} >
                    <ThemeMenu />
                    <ChannelMenu />
                </Box>
            </Drawer>
            <Box component='main' sx={{flexGrow:1, p:3}}>
                <Chat />
            </Box>
        </Box>
    )
}

짠~~~ 메인페이지가 많이 허전하죠??

컴포넌트들을 불러오는 역할만 하고 있는 페이지라 따로 딱히 많은 양의 코드를 가지고 있진 않아요. 

셀렉터로 theme를 불러오는건 아마 다음시간이나 마지막 시간에 설명 드리겠지만 간단히 말씀드리면 Slack 컨텐츠들의 배경색을 변경하는 기능을 가지고 있어요.

 

메인페이지에서 제일 먼저 불러오는 Header의 코드부터 한번 볼까요??

 

Header


function Header() {

    const {user, theme} = useSelector(state => state)
    const [anchorEl, setAnchorEl] = useState(null);
    const [showProfileModal, setShowProfileModal] = useState(false)

    const handleCloseMenu = () => {
        setAnchorEl(null)
    }

    const openProfile = useCallback(() => {
        setShowProfileModal(true)
        handleCloseMenu()
    }, [handleCloseMenu])

    const closeProfile = useCallback(() => {
        setShowProfileModal(false)
    }, [])


    const handleLogOut = async() => {
        await signOut(getAuth())
        setAnchorEl(null)
    }


    return (
        <>
        {/* TODO backgroundColor 테마적용 */}
            <AppBar position='fixed' sx={{zIndex:(theme)=> theme.zIndex.drawer+1, color:'#9a939b', backgroundColor:theme.mainTheme}}>
                <Toolbar sx={{display:'flex', justifyContent:'space-between', height:'50px'}}>
                    <Box sx={{display:'flex'}}>
                        <TagIcon />
                        <Typography variant='h6' component='div' >
                            SLACK
                        </Typography>
                    </Box>
                    <Box>
                        <IconButton onClick={(e)=>setAnchorEl(e.currentTarget)}>
                            <Typography variant='h6' component='div' sx={{color:"#fff"}}>
                                {user.currentUser?.displayName}
                            </Typography>
                            <Avatar sx={{marginLeft:'10px'}} alt='profileImage' src={user.currentUser?.photoURL} />
                        </IconButton>
                        <Menu sx={{mt:'45px'}} anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleCloseMenu} anchorOrigin={{vertical:'top', horizontal:'right'}}>
                            <MenuItem onClick={openProfile}>
                                <Typography textAlign="center">프로필 이미지</Typography>
                            </MenuItem>
                            <MenuItem onClick={handleLogOut}>
                                <Typography textAlign="center">Log Out</Typography>
                            </MenuItem>
                        </Menu>
                    </Box>
                </Toolbar>
            </AppBar>
            <ProfileModal open={showProfileModal} handleClose={closeProfile} />
        </>
    )
}

Header 파일에서 제일 중요하게 볼거는 로그아웃프로필 사진 변경하는 기능입니다.

로그아웃은 firebase에서 제공해주는 기능인 signOut을 사용하면 바로 로그아웃이 됩니다.

 

user의 정보는 우리가 저번에 회원가입할때 만들었던 리덕스에서 user라는 값을 가져오면 됩니다. 리덕스코드는 짧아서 따로 올리진 않을께요. 깃헙 가셔서 보시면 됩니다.

user에서 가져온 데이터로 닉네임과 프로필이미지를 출력해줍니다.

코드의 마지막줄인 ProfileModal이라는 컴포넌트에서 프로필 이미지를 변경하는 팝업 창이 나옵니다.

해당 컴포넌트가 내용이 길어서 오늘은 프로필 변경하는 모달쪽 코드를 리뷰하고 끝을 내야 겠네요.

먼저 총 완성된 코드를 보여드릴께요.

function ProfileModal({open, handleClose}) {
    const {user} = useSelector(state=>state)
    const [previewImage, setPreviewImage] = useState('');
    const [uploadedCroppedImage, setUploadedCroppedImage] = useState('')
    const [croppedImage, setCroppedImage] = useState('')
    const [blob, setBlob] = useState('')
    const avatarEditorRef = useRef()

    const closeModal = useCallback(() => {
        handleClose();
        setPreviewImage('')
        setCroppedImage('')
        setUploadedCroppedImage('')
    }, [handleClose])

    const chageProfile = useCallback((e) =>{
        const file = e.target.files[0]
        if(!file) return;
        const reader = new FileReader();
        reader.readAsDataURL(file)
        reader.addEventListener('load', ()=>{
            setPreviewImage(reader.result)
        });

    }, [])

    const cropImage = useCallback(()=>{
        avatarEditorRef.current.getImageScaledToCanvas().toBlob((blob)=>{
            const imageUrl = URL.createObjectURL(blob);
            setCroppedImage(imageUrl);
            setBlob(blob)
        });
    }, [])

    const changeProfile = useCallback( async () => {
        if(!user.currentUser?.uid) return;
        const storageRef = refStorage(getStorage(), `avatars/users/${user.currentUser.uid}`);
        const uploadTask = await uploadBytes(storageRef, blob);
        const downloadUrl = await getDownloadURL(uploadTask.ref);
        setUploadedCroppedImage(downloadUrl)

    }, [blob, user.currentUser?.uid])

    useEffect(()=>{
        if(!uploadedCroppedImage || !user.currentUser) return
        async function changeAvatar(){
            await updateProfile(user.currentUser, {
                photoURL:uploadedCroppedImage
            });
            const updates = {};
            updates["/users/" + user.currentUser.uid + "/avatar"] = uploadedCroppedImage;
            await update(ref(getDatabase()), updates);
            closeModal()
        }
        
        changeAvatar()
    }, [uploadedCroppedImage, user.currentUser, closeModal])

    return (
        <Dialog open={open} onClose={handleClose}>
            <DialogTitle>프로필 이미지 변경</DialogTitle>
            <DialogContent>
                <Stack direction='column' spacing={3}>
                    <Input 
                        type='file'
                        inputProps={{accept:'image/jpeg, image/jpg, image/png, image/gif'}}
                        label='변경할 프로필 이미지 선택'
                        onChange={chageProfile}
                    />
                    <div style={{display:'flex', alignItems:'center'}}>
                        {previewImage && (
                            <AvatarEditor 
                                image={previewImage} 
                                width={120} 
                                height={120} 
                                border={50} 
                                scale={2} 
                                style={{display:'inline'}} 
                                ref={avatarEditorRef}
                            />
                        )}
                        {
                            croppedImage && (
                                <img alt='cropped' style={{marginLeft:'50px'}} width={100} height={100} src={croppedImage} />
                            )
                        }
                    </div>
                </Stack>
            </DialogContent>
            <DialogActions>
                <Button onClick={closeModal}>취소</Button>
                {
                    previewImage && (
                        <Button onClick={cropImage}>이미지 자르기</Button>
                    )
                }
                {
                    croppedImage && (
                        <Button onClick={changeProfile}>프로필 이미지 저장</Button>
                    )
                }
            </DialogActions>
        </Dialog>
    )
}

와.... 코드가 진짜...저는 패캠의 강의를 들으면서 작업하고 있어서 했지만 지금 당장 저 혼자 코드를 작성하라고 하면.... 

코드 리뷰하려고 파일을 여는 순간 그냥 닫고 싶더라구요...

 

그래도 힘을내고 한번 작성해 볼께요.

closeMoal은 모달창을 닫는 기능이라 따로 설명하진 않을께요.

const chageProfile = useCallback((e) => {
    const file = e.target.files[0]
    if (!file) return;
    const reader = new FileReader();
    reader.readAsDataURL(file)
    reader.addEventListener('load', () => {
        setPreviewImage(reader.result)
    });

}, [])

헐... 이제보니 오타가 있었네요. changeProfile으로 하려고 했는데 근데 밑에 또 changeProfile이 있어서 따로 수정은 하지 않을께요.

file Input에 값이 들어오면 해당 이미지로 previewImage에 데이터를 넣는 기능을 가지고 있다.

여기서 Input이 하나 인데 왜 e.target.files[0]이라는 배열을 사용했냐면 file의 값은 배열로 들어온다. 그래서 우린 input이 하나지만 꼭 files에서 0번째의 데이터를 가지겠다고 선언을 해줘야 한다.

new FileReader 은 비동기적으로 파일의 데이터를 읽기 위한 기능이다. 왜 파일을 읽어야 하냐면 이미지 사이즈가 크면 영역을 잘라서 사용하기 위해서 미리보기화면에 파일의 정보를 전달하기 위한 용도이다.

프로필 이미지 자르기

위의 캡쳐 이미지를 보면 파일을 넣고 그 파일이 밑에 화면처럼 크롭 할 수있도록 미리보기가 나온다. 

해당 기능을 사용하기 위해서 FileReader가 필요해요.

 

이제 파일선택기능을 끝났으니 미리보기 화면과 크롭기능에서 대해서 볼께요.

const cropImage = useCallback(()=>{
    avatarEditorRef.current.getImageScaledToCanvas().toBlob((blob)=>{
        const imageUrl = URL.createObjectURL(blob);
        setCroppedImage(imageUrl);
        setBlob(blob)
    });
}, [])


<div style={{display:'flex', alignItems:'center'}}>
    {previewImage && (
        <AvatarEditor 
            image={previewImage} 
            width={120} 
            height={120} 
            border={50} 
            scale={2} 
            style={{display:'inline'}} 
            ref={avatarEditorRef}
        />
    )}
    {
        croppedImage && (
            <img alt='cropped' style={{marginLeft:'50px'}} width={100} height={100} src={croppedImage} />
        )
    }
    
    {
        previewImage && (
            <Button onClick={cropImage}>이미지 자르기</Button>
        )
    }
</div>

미리보기와 크롭은 UI코드랑 함께 볼께요.

Ref로 AvatartEditor을 선택하고 참고로 해당 컴포넌트는 react-avatar-editor에서 제공하는 기능입니다.

image는 크롭 하기 위한 이미지를 보여주고 영역을 드래그고 움직이고 영역 자르기 버튼을 누르면 img 태그에 영역만큼 잘린 이미지가 미리보기로 보여지는 기능입니다.

 

cropImage에 사용한 코드를 보면 getImageScaledToCanvas으로 이미지를 캔버스로 만들고 Blob 객체를 생성하고

잘린 이미지를 파일을 새로 만들고 해당 정보를 setCroppedImage에 저장한다.

 

그런데 avatarEditor은 다음에 작업할때 한번 더 공부해야겠다. 정확히 아직은 어떤 기능을 하는지 알지 못하겠다.

 

마지막으로 저장했던 크롭이미지를 최종적으로 내 프로필 사진으로 저장 하는 기능이다.

const changeProfile = useCallback( async () => {
    if(!user.currentUser?.uid) return;
    const storageRef = refStorage(getStorage(), `avatars/users/${user.currentUser.uid}`);
    const uploadTask = await uploadBytes(storageRef, blob);
    const downloadUrl = await getDownloadURL(uploadTask.ref);
    setUploadedCroppedImage(downloadUrl)

}, [blob, user.currentUser?.uid])

useEffect(()=>{
    if(!uploadedCroppedImage || !user.currentUser) return
    async function changeAvatar(){
        await updateProfile(user.currentUser, {
            photoURL:uploadedCroppedImage
        });
        const updates = {};
        updates["/users/" + user.currentUser.uid + "/avatar"] = uploadedCroppedImage;
        await update(ref(getDatabase()), updates);
        closeModal()
    }

    changeAvatar()
}, [uploadedCroppedImage, user.currentUser, closeModal])

크롭해서 저장한 이미지를 storage에 업로드를 하고 저장한 이미지를 다시 다운로드를 해서 state에 저장한다.

그 다음에 useEffect를 사용해서 user의 데이터 목록중에 photoURL에 값을 크롭한 이미지로 변경한다.

그리고 DB에 user데이터의 avata에도 값을 변경해 준다.

 

이로써 프로필 이미지 변경하는 기능이 끝이 났다.

그래도 프로젝트 몇개를 했다고 오류들이 잘안나서 좋았다. 처음에는 무작정 따라할때는 오류도 많이 나고 해서 더 힘들고 했는데 점점 React 코드에 익숙해 지고 있는거 같아서 기분이 좋다.

 

다음 시간에는 채널목록과 채팅 기능을 작업하고 내용이 길지 않으면 테마 부분도 리뷰할 예정이다.

 

그럼 빠잉~~

728x90
반응형