[React] 트위터 클론 (+ Firebase) - Profile

jjjjj·2022년 10월 10일
1

트위터 클론

목록 보기
6/6

👀 깃허브 링크
😎 클론 사이트 바로가기


⚠ 아직 부족한 점이 많아 정보 및 코드가 올바르지 않을 수 있습니다. 이 점 양해 부탁드리며 수정이 필요한 부분은 피드백 주시면 감사하겠습니다!

이슈가 있었던 부분은 제목 옆에 ❗를 붙였습니다.


➕ 기능 및 특징

  • 카테고리 세분화 및 '프로필 수정', '북마크' 탭은 본인 프로필에서만 보일 수 있게 함

  • '프로필 수정' 클릭 시 모달창이 활성화 되어 배경·프로필 이미지, 닉네임·자기소개 추가/변경/삭제 가능

  • 가입일과 팔로잉, 팔로워 숫자 확인
    • Right Bar 팔로우 추천에서의 팔로우를 다른 유저의 프로필 탭에서도 가능

  • 프로필 탭에서도 로그아웃 가능


✨ 카테고리 및 라우터

  • 리트윗, 좋아요, 북마크 탭을 각 트윗과 답글로 더 구분 지었습니다.
    • <Route path={}>에 경로를 배열로 지정하여 여러 경로에서 같은 컴포넌트를 보일 수 있게 했습니다.
      니다.


└ 카테고리 (리트윗)

(좋아요, 북마크 부분도 동일)

프로필 -> 리트윗 탭 부분

// creatorInfo = URL에서 이메일 추출한 유저 정보

const userEmail = pathname.split("/")[3];

useEffect(() => {
  const paths = {
    mynweets: 1,
    replies: 2,
    renweets: 3,
    like: 4,
    bookmark: 5,
  };

  const selectedValue = paths[pathname.split("/")[2]];

  setSelected(selectedValue);
}, [pathname, userObj.email]);

return (
  // ... 생략
  <SelectMenuBtn
    num={3}
    selected={selected}
    url={"/profile/renweets/" + userEmail}
    text={"리트윗"}
  />
      
  <Switch>
    // ... 생략
    <Route
      path={[
        "/profile/renweets/" + userEmail,
        "/profile/renweetsreplies/" + userEmail,
      ]}
    >
      <ProfileReNweetBox
        userObj={userObj}
        creatorInfo={creatorInfo}
      />
    </Route>
  </Switch>
)

- 프로필 -> 리트윗 탭 클릭 시 노출되는 컴포넌트

const ProfileReNweetBox = ({ userObj, creatorInfo }) => {
  const location = useLocation();
  const [selected, setSelected] = useState(1);

  useEffect(() => {
    if (location.pathname.includes("/renweets/")) {
      setSelected(1);
    } else if (location.pathname.includes("/renweetsreplies/")) {
      setSelected(2);
    }
  }, [location.pathname]);

  return (
    <>
      <div className={styled.container}>
        <div className={styled.main__container}>
          <nav className={styled.categoryList}>
            <SelectMenuBtn
              num={1}
              selected={selected}
              url={
                location.pathname.includes("/user/")
                  ? "/user/renweets/" + creatorInfo.email
                  : "/profile/renweets/" + creatorInfo.email
              }
              text={"트윗"}
            />
            <SelectMenuBtn
              num={2}
              selected={selected}
              url={
                location.pathname.includes("/user/")
                  ? "/user/renweetsreplies/" + creatorInfo.email
                  : "/profile/renweetsreplies/" + creatorInfo.email
              }
              text={"답글"}
            />
          </nav>
        </div>

        <Switch>
          <Route
            path={
              location.pathname.includes("/user/")
                ? "/user/renweets/" + creatorInfo.email
                : "/profile/renweets/" + creatorInfo.email
            }
          >
            <ProfileReNweets userObj={userObj} creatorInfo={creatorInfo} />
          </Route>
          <Route
            path={
              location.pathname.includes("/user/")
                ? "/user/renweetsreplies/" + creatorInfo.email
                : "/profile/renweetsreplies/" + creatorInfo.email
            }
          >
            <ProfileReNweetsReplies
              userObj={userObj}
              creatorInfo={creatorInfo}
            />
          </Route>
        </Switch>
      </div>
    </>
  );
};

export default ProfileReNweetBox;


✨ 프로필 수정 (❗)

  • 모달창 활성 시 각 영역들을 추가·변경·삭제 할 수 있습니다.
    • 변경사항 없을 시 프로필 수정 버튼 비활성화

  • 파일의 용량이 클 때 업로드가 안 되기 때문에 이미지 크기와 사이즈를 압축(조정)해줄 수 있는 browser-image-compression 라이브러리를 사용했습니다.

└ 모달창

// UpdateProfileModal.js 컴포넌트 內

const dispatch = useDispatch();
const currentUser = useSelector((state) => state.user.currentUser);
const [newDisplayName, setNewDisplayName] = useState(creatorInfo.displayName);
const [desc, setDesc] = useState(creatorInfo.description);
const [editAttachment, setEditAttachment] = useState(creatorInfo.photoURL);
const [editAttachmentBg, setEditAttachmentBg] = useState(creatorInfo.bgURL);
const [isDeleteProfileURL, setIsDeleteProfileURL] = useState(false);
const [isDeleteBgURL, setIsDeleteBgURL] = useState(false);
const [isAddFile, setIsAddFile] = useState(null);

const onChangeInfo = (e, type) => {
  if (type === "displayName") {
    setNewDisplayName(e.target.value);
  } else if (type === "description") {
    setDesc(e.target.value);
  }
};

// 이미지 압축
const compressImage = async (image) => {
  try {
    const options = {
      maxSizeMb: 1,
      maxWidthOrHeight: 600,
    };
    return await imageCompression(image, options);
  } catch (e) {
    console.log(e);
  }
};

// 이미지 URL로 바꾸는 로직 - hook 예정(useFileChange)
const onFileChange = async (e) => {
  setIsAddFile(true);
  const theFile = e.target.files[0]; // 파일 1개만 첨부
  const compressedImage = await compressImage(theFile); // 이미지 압축
  const reader = new FileReader(); // 파일 이름 읽기

  reader.onloadend = (finishedEvent) => {
    setEditAttachment(finishedEvent.currentTarget.result);
  };

  /* 파일 선택 누르고 이미지 한 개 선택 뒤 다시 파일선택 누르고 취소 누르면
     Failed to execute 'readAsDataURL' on 'FileReader': parameter 1 is not of type 'Blob'. 이런 오류가 나옴.
     -> if문으로 예외 처리 */
  if (theFile) {
    reader.readAsDataURL(compressedImage);
  }
};

const onFileBgChange = async (e) => {
  setIsAddFile(true);
  const theFile = e.target.files[0]; // 파일 1개만 첨부
  const compressedImage = await compressImage(theFile); // 이미지 압축
  const reader = new FileReader(); // 파일 이름 읽기

  reader.onloadend = (finishedEvent) => {
    setEditAttachmentBg(finishedEvent.currentTarget.result);
  };

  /* 파일 선택 누르고 이미지 한 개 선택 뒤 다시 파일선택 누르고 취소 누르면
     Failed to execute 'readAsDataURL' on 'FileReader': parameter 1 is not of type 'Blob'. 이런 오류가 나옴.
     -> if문으로 예외 처리 */
  if (theFile) {
    reader.readAsDataURL(compressedImage);
  }
};

const onDeleteProfileClick = async () => {
  const ok = window.confirm("프로필 사진을 삭제하시겠어요?");
 // 이미지 없는 글 삭제 시 렌더링 하려는 이미지 값을 찾는 에러가 떠서 예외 처리
  if (ok) {
    setIsDeleteProfileURL(!isDeleteProfileURL);
    setEditAttachment(noneProfile);
    setIsAddFile(false);
  }
};

const onDeleteBgClick = async () => {
  const ok = window.confirm("배경사진을 삭제하시겠어요?");
  // 이미지 없는 글 삭제 시 렌더링 하려는 이미지 값을 찾는 에러가 떠서 예외 처리
  if (ok) {
    setIsDeleteBgURL(!isDeleteBgURL);
    setEditAttachmentBg(bgImg);
    setIsAddFile(false);
  }
};

// 업데이트 버튼
const onSubmit = async (e) => {
  e.preventDefault();

  await updateDoc(doc(dbService, "users", creatorInfo.email), {
    displayName: newDisplayName, // 바뀐 이름 업데이트
    photoURL: editAttachment,
    bgURL: editAttachmentBg,
    description: desc,
  });

  dispatch(
    setCurrentUser({
      displayName: newDisplayName, // 바뀐 이름 디스패치
      photoURL: editAttachment,
      bgURL: editAttachmentBg,
      description: desc,
      ...currentUser,
    })
  );

  // 프로필 이미지 삭제 버튼 클릭 시
  if (isDeleteProfileURL) {
    await updateDoc(doc(dbService, "users", creatorInfo.email), {
      photoURL: noneProfile,
    });
    dispatch(
      setCurrentUser({
        photoURL: noneProfile,
        displayName: newDisplayName,
        description: desc,
        ...currentUser,
      })
    );
  }

  // 배경 이미지 삭제 버튼 클릭 시
  if (isDeleteBgURL) {
    await updateDoc(doc(dbService, "users", creatorInfo.email), {
      bgURL: bgImg,
    });
    dispatch(
      setCurrentUser({
        bgURL: bgImg,
        displayName: newDisplayName,
        description: desc,
        ...currentUser,
      })
    );
  }

  alert(`프로필이 수정되었습니다.`);
  toggleEdit(false);
};


✨ 가입일, 팔로우 관련

  • 가입일, 팔로우 관련 숫자는 Firebase 필드의 값을 불러왔습니다.

  • 유저 팔로우는 Right Bar팔로우 추천 영역이 아닌 다른 유저의 프로필에서도 팔로우 할 수 있게 했습니다.

- profile 컴포넌트 중 일부

// creatorInfo = URL에서 이메일 추출한 유저 정보

const userEmail = pathname.split("/")[3];// URL 중 이메일 부분에 해당

// 본인 정보 가져오기
useEffect(() => {
  const unsubscribe = onSnapshot(
    doc(dbService, "users", currentUser.email),
    (doc) => {
      setMyInfo(doc.data());
      setFbLoading((prev) => ({ ...prev, myInfo: true }));
    }
  );

  return () => {
    unsubscribe();
  };
}, [currentUser.email]);

return (
  <div className={styled.profile__createdAt}>
    <BsCalendar3 />
    <p>가입일 : {timeToString3(creatorInfo.createdAtId)}</p>
  </div>
  <div className={styled.profile__followInfo}>
    <p>
      <b>{creatorInfo.following?.length}</b> 팔로잉
    </p>
    <p>
      <b>{creatorInfo.follower?.length}</b> 팔로워
    </p>
  </div>
)

- 경과 시간 custom hook 일부

const timeToString3 = (timestamp) => {
  let date = new Date(timestamp);
  let str =
      date.getFullYear() +
      "년 " +
      (date.getMonth() + 1) +
      "월 " +
      date.getDate() +
      "일 ";
  return str;
};


✔ 문제 및 해결

❗ 업데이트

  • 프로필 업데이트 하는 로직 중 값을 Reduxstoredispatch 하는 코드가 있는데 새로고침하면 정보가 사라져서 렌더링이 되지 않는 현상이 있었습니다.
    • 새로고침을 해도 정보를 남아있게 해주는 react-persist를 사용해서 해결했습니다.

- store.js

import { composeWithDevTools } from "redux-devtools-extension";
import { persistStore, persistReducer } from "redux-persist";
import { createStore } from "redux";
import rootReducer from "../reducer";
// localStorage에 저장하고 싶으면
import storage from "redux-persist/lib/storage";
// session Storage에 저장하고 싶으면
// import storageSession from "redux-persist/lib/storage/session";

//persist 설정
const persistConfig = {
  key: "root",
  storage,
  // whitelist: ["특정 리듀서"] // 특정한 reducer만 localStorage에 저장하고 싶을 경우
};

// persistedReducer 생성
const persistedReducer = persistReducer(persistConfig, rootReducer);

// store, persistor를 리턴하는 함수
const configureStore = () => {
  const store = createStore(persistedReducer, composeWithDevTools());
  const persistor = persistStore(store);
  return { store, persistor };
};

export default configureStore;

- index.js

const { store, persistor } = configureStore();

root.render(
  <Provider store={store}>
    <PersistGate loading={null} persistor={persistor}>
      <App />
    </PersistGate>
  </Provider>
);


📌 참고

- Route 경로 배열
- Redux-Persist 관련 1

profile
의미있게 하기~.~

0개의 댓글