이 포스팅은 노마드 코더님의 트위터 클론코딩을 학습 후 작성하였습니다. (무료!!)
https://nomadcoders.co/nwitter
트위터 클론코딩 – 노마드 코더 Nomad Coders
React Firebase for Beginners
nomadcoders.co
Profile Image
이제 텅 비어있는 프로필 페이지를 만들어보자. 프로필 페이지는 사용자의 프로필 이미지와, 사용자 이름 그리고 마지막에는 사용자가 쓴 트윗내용들이 정리돼서 보일 것이다.
export default function Profile () {
return (
< Wrapper >
< AvatarUpload >
< AvatarImg />
</ AvatarUpload >
< AvatarInput type = "file" accept = "image/*" />
< Name ></ Name >
</ Wrapper >
)
}
기본적인 프로필페이지의 구성이다.
import styled from "styled-components" ;
import { auth } from "../firebase"
import { useState } from "react" ;
const Wrapper = styled . div `
display : flex ;
align-items : center ;
flex-direction : column ;
gap : 20px ;
` ;
const AvatarUpload = styled . label `
width : 80px ;
overflow : hidden ;
height : 80px ;
border-radius : 50% ;
background-color : #1d9bf0 ;
cursor : pointer ;
display : flex ;
justify-content : center ;
align-items : center ;
svg {
width : 50px ;
}
` ;
const AvatarImg = styled . img `
width : 100% ;
` ;
const AvatarInput = styled . input `
display : none ;
` ;
const Name = styled . span `
font-size : 22px ;
` ;
export default function Profile () {
const user = auth . currentUser ;
const [ avatar , setAvatar ] = useState ( user ?. photoURL );
const onAvatarCahange = ( e : React . ChangeEvent < HTMLInputElement >) => {
}
return (
< Wrapper >
< AvatarUpload htmlFor = "avatar" >
{ Boolean ( avatar ) ? < AvatarImg src = { user ?. photoURL } /> : < svg dataSlot = "icon" fill = "none" strokeWidth = { 1.5 } stroke = "currentColor" viewBox = "0 0 24 24" xmlns = "http://www.w3.org/2000/svg " aria-hidden = "true" >
< path strokeLinecap = "round" strokeLinejoin = "round" d = "M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</ svg > }
</ AvatarUpload >
< AvatarInput onChange = { onAvatarCahange } id = "avatar" type = "file" accept = "image/*" />
< Name > { user ?. displayName ? user . displayName : "Anonymous" } </ Name >
</ Wrapper >
)
}
적당한 css를 입혀주고 user의 photoURL이 존재한다면 보여주고, 그렇지 않으면 작은 아이콘을 보여준다.
이번에는 따로 미리 보기를 만들지 않고 해당 파일을 선택과 동시에 업로드해 보자. 여기서 또 S3의 버킷과 설정들을 해야 하는데, 이미 세 개의 컴포넌트에 똑같은 변수들이 늘어났기 때문에 업로드 함수를 만들어서 캡슐화하는 작업도 같이 해보도록 하자. 먼저 S3ImageUpload.tsx 파일을 생성해 준다.
import AWS from "aws-sdk" ;
export default function S3imageUpload ( file : File , name : string | null ) {
//S3 setting--------------------------------------
const ACCESS_KEY = import . meta . env . VITE_ACCESS_KEY
const SECRET_KEY = import . meta . env . VITE_SECRET_KEY
const REGION = "ap-northeast-2" ;
const S3_BUKKET = "sulgibucket" ;
AWS . config . update ({
accessKeyId : ACCESS_KEY ,
secretAccessKey : SECRET_KEY ,
});
const myBuket = new AWS . S3 ({
params : { Buket : S3_BUKKET },
region : REGION ,
});
//------------------------------------------------
const uploadToS3 = async ( file : File , name : string | null ) => {
let image = file . name ;
let imageKey = name ;
try {
const params = {
ACL : "public-read" ,
Body : file ,
Bucket : S3_BUKKET ,
ContentType : file . type ,
Key : "upload/" + file . name + name ,
};
const url = await myBuket . upload ( params , ( error : any ) => {
console . log ( error );
}). promise ()
image = url . Location ;
imageKey = url . Key ;
console . log ( url . Location );
} catch ( error ) {
console . log ( error );
}
return { image , imageKey };
}
return uploadToS3 ( file , name );
}
인자로는 file과 string값의 이름을 받는다. 받은 인자를 S3에 업로드 후 최종적으로 url과 Key값을 반환한다.
이후 Profile page에서 함수를 호출한다.
export default function Profile () {
const user = auth . currentUser ;
const [ avatar , setAvatar ] = useState ( user ?. photoURL );
const onAvatarCahange = async ( e : React . ChangeEvent < HTMLInputElement >) => {
const { files } = e . target ;
if ( ! user ) return ;
if ( ! files || files . length === 0 ) return ;
if ( files [ 0 ]. size > 1 * 1024 * 1024 ) {
alert ( "Image file size should be less than 1Mb" );
return ;
};
const s3url = S3ImageUpload ( files [ 0 ], user . displayName );
setAvatar (( await s3url ). image );
}
useEffect (() => {
const updatePfo = async () => {
if ( ! user ) return ;
await updateProfile ( user , {
photoURL : avatar ,
});
}
updatePfo ();
}, [ avatar ]);
비동기에 대한 처리들이 생각했던 것보다 더 까다롭다는 것을 느꼈다. 값이 제대로 들어오지 않아 비동기 처리에 대한 부분들을 많이 찾아보았다. 우선 파일의 유무와 크기를 확인하고, S3ImageUpload()를 이용해 image의 url값을 받아온다. 그리고 avatar의 이미지를 업데이트시켜 주면 useEffect를 이용해 Profile의 사진도 업데이트해 준다. 추후에 다른 부분에 쓰였던 코드들도 캡슐화해 주도록 하자. (tweet.tsx, post-tweet-form)
User's Timeline
useEffect (() => {
const fetchTweets = async () => {
const tweetQuery = query (
collection ( db , "tweets" ),
where ( "userId" , "==" , user ?. uid ),
orderBy ( "createdAt" , "desc" ),
limit ( 25 )
);
const snapshot = await getDocs ( tweetQuery );
const tweets = snapshot . docs . map (( doc ) => {
const { tweet , createdAt , userId , username , photo , photoKey } = doc . data ();
console . log ( 1 );
return {
tweet , createdAt , userId , username , photo , photoKey , id : doc . id
};
});
setTweets ( tweets );
};
fetchTweets ();
}, []);
Timeline.tsx에서 했던 것과 마찬가지로, useEffect() 안에 fetchTweets() 함수를 만들어 준다. 이 전과 다른 점은 tweetQuery에 where 구문이 들어가 있는 것이다. userId의 값이 현재 로그인되어 있는 사용자와 같은 Tweet만 가져올 것이다.
const Tweets = styled . div `
display : flex ;
flex-direction : column ;
gap : 10px ;
` ;
return (
< Wrapper >
< AvatarUpload htmlFor = "avatar" >
{ Boolean ( avatar ) ? < AvatarImg src = { avatar } /> : < svg dataSlot = "icon" fill = "none" strokeWidth = { 1.5 } stroke = "currentColor" viewBox = "0 0 24 24" xmlns = "http://www.w3.org/2000/svg " aria-hidden = "true" >
< path strokeLinecap = "round" strokeLinejoin = "round" d = "M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</ svg > }
</ AvatarUpload >
< AvatarInput onChange = { onAvatarCahange } id = "avatar" type = "file" accept = "image/*" />
< Name > { user ?. displayName ? user . displayName : "Anonymous" } </ Name >
< Tweets >
{ tweets . map ( tweet => < Tweet key = { tweet . id } { ... tweet } /> ) }
</ Tweets >
</ Wrapper >
)
<Tweets>라는 container를 만들어 주고 그 안에 Tweet을 map으로 뿌려준다. 하지만, 실행을 바로 하면 문제가 하나 생긴다.
console에 쿼리에 인덱스가 필요하다는 오류메시지가 뜬다. 위의 주소를 클릭하면 해당 페이지로 이동할 수 있다.
저장을 누르면 방금 사용한 where 구문의 필터가 등록된다. firebase database는 noSQL 형식이라 이러한 쿼리에 관한 설정을 따로 해주어야 사용이 가능하다. 설정이 완료된 후 새로고침을 하면
내가 작성한 글들만 나오는 것을 볼 수 있다.
Code challenge
마지막으로 user의 displayName을 변경하는 것으로 마무리해 보자.
< Name >
{ isEditing ? < TextArea onChange = { onChange } rows = { 1 } maxLength = { 20 } value = { inputText as string } required /> : user ?. displayName || "Anonymous" }
< EditButton >
{ isEditing ? < svg onClick = { onEdit } fill = "none" strokeWidth = { 1.5 } stroke = "currentColor" viewBox = "0 0 24 24" xmlns = "http://www.w3.org/2000/svg " aria-hidden = "true" >
< path strokeLinecap = "round" strokeLinejoin = "round" d = "M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636" />
</ svg > : < svg onClick = { onEdit } data-slot = "icon" fill = "none" stroke-width = "1.5" stroke = "currentColor" viewBox = "0 0 24 24" xmlns = "http://www.w3.org/2000/svg " aria-hidden = "true" >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" ></ path >
</ svg > }
{ isEditing ? < svg onClick = { editDone } data-slot = "icon" fill = "none" stroke-width = "1.5" stroke = "currentColor" viewBox = "0 0 24 24" xmlns = "http://www.w3.org/2000/svg " aria-hidden = "true" >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" ></ path >
</ svg > : null }
</ EditButton >
</ Name >
먼저 수정을 할 수 있는 버튼과 수정 버튼을 누르면 현재의 이름이 textarea로 변경되도록 해준다.
const onEdit = () => {
setEditing ( ! isEditing );
setInputText ( user ?. displayName );
}
const onChange = ( e : React . ChangeEvent < HTMLTextAreaElement >) => {
setInputText ( e . target . value );
}
const editDone = async () => {
const ok = confirm ( "Are you sure you want to edit this profile?" );
if ( ! ok || ! user ) return ;
const special_pattern = / [ `~!@#$%^&*| \\\'\" ;: \/ ? ] / gi ;
if ( inputText ! . search ( /\s/ g ) > - 1 ) {
alert ( "There is a blank space in your name." );
} else if ( special_pattern . test ( inputText ! ) == true ){
alert ( "You can't use special characters." );
} else {
console . log ( "you can use" );
updateProfile ( user , { displayName : inputText });
window . location . reload ();
}
}
edit버튼은 스위치처럼 이용해 취소버튼을 만들어주고, 완료 버튼을 누르면 수정이 되도록 해준다.
profile.tsx 전체코드
import styled from "styled-components" ;
import { auth , db } from "../firebase"
import { useEffect , useState } from "react" ;
import { updateProfile } from "firebase/auth" ;
import S3ImageUpload from "../components/s3ImageUpload" ;
import { collection , getDocs , limit , orderBy , query , where } from "firebase/firestore" ;
import { ITweet } from "../components/timeline" ;
import Tweet from "../components/tweet" ;
const Wrapper = styled . div `
display : flex ;
align-items : center ;
flex-direction : column ;
gap : 15px ;
` ;
const AvatarUpload = styled . label `
width : 80px ;
overflow : hidden ;
height : 80px ;
border-radius : 50% ;
background-color : #1d9cf038 ;
cursor : pointer ;
display : flex ;
justify-content : center ;
align-items : center ;
svg {
width : 50px ;
}
` ;
const AvatarImg = styled . img `
width : 100% ;
` ;
const AvatarInput = styled . input `
display : none ;
` ;
const Tweets = styled . div `
display : flex ;
flex-direction : column ;
gap : 10px ;
width : 65% ;
` ;
const Name = styled . div `
display : flex ;
flex-direction : column ;
align-items : center ;
font-size : 22px ;
` ;
const EditButton = styled . div `
display : flex ;
display : inline-block ;
align-items : center ;
flex-direction : row-reverse ;
text-align : center ;
cursor : pointer ;
width : 60px ;
svg {
width : 30px ;
}
` ;
const TextArea = styled . textarea `
border : 1px solid white ;
padding : 2px ;
border-radius : 10px ;
font-size : 22px ;
color : white ;
background-color : black ;
width : 40% ;
resize : none ;
& :focus {
outline : none ;
border-color : #1d9bf0 ;
}
` ;
const TextOne = styled . div `
padding-top : 30px ;
text-align : left ;
` ;
export default function Profile () {
const user = auth . currentUser ;
const [ avatar , setAvatar ] = useState ( user ?. photoURL );
const [ inputText , setInputText ] = useState ( user ?. displayName );
const [ isEditing , setEditing ] = useState ( false );
const [ tweets , setTweets ] = useState < ITweet []>([]);
const onAvatarCahange = async ( e : React . ChangeEvent < HTMLInputElement >) => {
const { files } = e . target ;
if ( ! user ) return ;
if ( ! files || files . length === 0 ) return ;
if ( files [ 0 ]. size > 1 * 1024 * 1024 ) {
alert ( "Image file size should be less than 1Mb" );
return ;
};
const s3url = S3ImageUpload ( files [ 0 ], user . displayName );
setAvatar (( await s3url ). image );
}
useEffect (() => {
const updatePfo = async () => {
if ( ! user ) return ;
await updateProfile ( user , {
photoURL : avatar ,
});
}
updatePfo ();
}, [ avatar ]);
useEffect (() => {
const fetchTweets = async () => {
const tweetQuery = query (
collection ( db , "tweets" ),
where ( "userId" , "==" , user ?. uid ),
orderBy ( "createdAt" , "desc" ),
limit ( 25 )
);
const snapshot = await getDocs ( tweetQuery );
const tweets = snapshot . docs . map (( doc ) => {
const { tweet , createdAt , userId , username , photo , photoKey } = doc . data ();
return {
tweet , createdAt , userId , username , photo , photoKey , id : doc . id
};
});
setTweets ( tweets );
};
fetchTweets ();
}, []);
const onEdit = () => {
setEditing ( ! isEditing );
setInputText ( user ?. displayName );
}
const onChange = ( e : React . ChangeEvent < HTMLTextAreaElement >) => {
setInputText ( e . target . value );
}
const editDone = async () => {
const ok = confirm ( "Are you sure you want to edit this profile?" );
if ( ! ok || ! user ) return ;
const special_pattern = / [ `~!@#$%^&*| \\\'\" ;: \/ ? ] / gi ;
if ( inputText ! . search ( /\s/ g ) > - 1 ) {
alert ( "There is a blank space in your name." );
} else if ( special_pattern . test ( inputText ! ) == true ){
alert ( "You can't use special characters." );
} else {
console . log ( "you can use" );
updateProfile ( user , { displayName : inputText });
window . location . reload ();
}
}
return (
< Wrapper >
< AvatarUpload htmlFor = "avatar" >
{ avatar ? < AvatarImg src = { avatar } /> : < svg fill = "none" strokeWidth = { 1.5 } stroke = "currentColor" viewBox = "0 0 24 24" xmlns = "http://www.w3.org/2000/svg " aria-hidden = "true" >
< path strokeLinecap = "round" strokeLinejoin = "round" d = "M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</ svg > }
</ AvatarUpload >
< AvatarInput onChange = { onAvatarCahange } id = "avatar" type = "file" accept = "image/*" />
< Name >
{ isEditing ? < TextArea onChange = { onChange } rows = { 1 } maxLength = { 20 } value = { inputText as string } required /> : user ?. displayName || "Anonymous" }
< EditButton >
{ isEditing ? < svg onClick = { onEdit } fill = "none" strokeWidth = { 1.5 } stroke = "currentColor" viewBox = "0 0 24 24" xmlns = "http://www.w3.org/2000/svg " aria-hidden = "true" >
< path strokeLinecap = "round" strokeLinejoin = "round" d = "M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636" />
</ svg > : < svg onClick = { onEdit } data-slot = "icon" fill = "none" stroke-width = "1.5" stroke = "currentColor" viewBox = "0 0 24 24" xmlns = "http://www.w3.org/2000/svg " aria-hidden = "true" >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" ></ path >
</ svg > }
{ isEditing ? < svg onClick = { editDone } data-slot = "icon" fill = "none" stroke-width = "1.5" stroke = "currentColor" viewBox = "0 0 24 24" xmlns = "http://www.w3.org/2000/svg " aria-hidden = "true" >
< path stroke-linecap = "round" stroke-linejoin = "round" d = "M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" ></ path >
</ svg > : null }
</ EditButton >
</ Name >
< TextOne >
My Tweets
</ TextOne >
< Tweets >
{ tweets . map ( tweet => < Tweet key = { tweet . id } { ... tweet } /> ) }
</ Tweets >
</ Wrapper >
)
}
이로써 트위터 클론코딩이 끝이 났다. 마지막으로 배포를 해주면 강의가 마무리된다.
git link : https://github.com/leesulgi66/nwitter-reloaded
GitHub - leesulgi66/nwitter-reloaded
Contribute to leesulgi66/nwitter-reloaded development by creating an account on GitHub.
github.com
이전 글 :
[노마드 코더] ReactJS 트위터 클론 코딩 - Router [노마드 코더] ReactJS 트위터 클론 코딩 - Authentication [노마드 코더] ReactJS 트위터 클론 코딩 - Github Login [노마드 코더] ReactJS 트위터 클론 코딩 - Tweeting
[노마드 코더] ReactJS 트위터 클론 코딩 - Uploading Images with S3 [노마드 코더] ReactJS 트위터 클론 코딩 - Fetching Timeline
[노마드 코더] ReactJS 트위터 클론 코딩 - Realtime Tweets & Delete
[노마드 코더] ReactJS 트위터 클론 코딩 - Edit tweet(code challenge)