[개인 포트폴리오] [트위터 클론 SNS Deli] 분석 Part. 10 'profile.tsx'

2024. 7. 8. 18:46웹개발 포트폴리오

728x90
반응형

profile.tsx

import { styled } from "styled-components";
import { auth, db, storage } from "../firebase";
import { useEffect, useState } from "react";
import { getDownloadURL, ref, uploadBytes } from "firebase/storage";
import { updateProfile, getAuth } from "firebase/auth";
import { Timestamp, collection, deleteDoc, doc, getDoc, getDocs, limit, onSnapshot, orderBy, query, setDoc, where, updateDoc } from "firebase/firestore";
import Tweet from "../component/post";
import { ITweet } from "../component/timeline";
import { useParams } from "react-router-dom";
import Modal, { Styles } from "react-modal";

Modal.setAppElement('#root');

const Wrapper = styled.div`
  display: flex;
  align-items: center;
  flex-direction: column;
  gap: 20px;
`;

const AvatarUpload = styled.label<{ disabled?: boolean }>`
  width: 80px;
  overflow: hidden;
  height: 80px;
  border-radius: 50%;
  background-color: ${(props) => (props.disabled ? "#ccc" : "#9D80F5")};
  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
  display: flex;
  justify-content: center;
  align-items: center;
  pointer-events: ${(props) => (props.disabled ? "none" : "auto")}; /* 클릭 방지 */
  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: 100%;
  max-height: 500px;
  overflow-y: scroll;

  &::-webkit-scrollbar {
    display: none;
  }
  -ms-overflow-style: none;
  scrollbar-width: none;
  padding-bottom: 350px;
`;

const Name = styled.span`
  font-family: pretendard-regular;
  font-size: 22px;
  display: flex;
  align-items: center;
  gap: 10px;
  color: black;
`;

const Input = styled.input`
  font-size: 22px;
  font-family: pretendard-regular;
  padding: 5px;
`;

const EditButton = styled.div`
  display: flex;
  justify-content: center;
  align-items: center;
  cursor: pointer;
  svg {
    width: 30px;
    height: 30px;
  }
`;

interface IButton {
  $isRed?: boolean;
}

const Button = styled.button<IButton>`
  border: none;
  background-color: ${(props) => (props.$isRed ? "#F5957F" : "#9D7FF5")};
  color: black;
  cursor: pointer;
  padding: 5px 10px;
  border-radius: 5px;
`;

interface ICloseButton {
  backgroundColor?: string;
}

const CloseButton = styled.button<ICloseButton>`
  background-color: none;
  color: #8F89A1;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  font-size: 15px;
  margin-top: auto; /* auto로 설정하여 하단에 위치 */
  align-self: flex-end; /* 오른쪽 하단에 위치 */
`;

interface IFriend {
  userId: string;
  username: string;
}

const FriendsListButton = styled(Button)`
  background-color: #8E88A0;
  color: white;
  margin-top: 20px;
`;

const customStyles: Styles = {
  overlay: {
    backgroundColor: 'rgba(0, 0, 0, 0.5)', // 배경 색상 설정
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
  },
  content: {
    top: 'auto',
    left: 'auto',
    right: 'auto',
    bottom: 'auto',
    margin: '0 auto',
    width: '30%', // 모달 너비
    height: '50%', // 모달 높이
    backgroundColor: '#fff',
    borderRadius: '10px',
    padding: '20px',
    boxShadow: '0 4px 8px rgba(0, 0, 0, 0.1)',
    display: 'flex',
    flexDirection: 'column',
    color: 'black',
  },
};

// 프로필 컴포넌트 정의
const Profile: React.FC = () => {
  const { userId } = useParams<{ userId: string }>(); // URL 파라미터에서 userId를 가져옴
  const user = auth.currentUser; // 현재 로그인한 사용자 정보
  const [editMode, setEditMode] = useState(false); // 편집 모드 상태
  const [username, setUsername] = useState<string | null>(null); // 사용자 이름 상태
  const [lastValue, setLastValue] = useState<string | null>(null); // 마지막 값 상태
  const [nameLoading, setNameLoading] = useState(false); // 이름 저장 중 상태
  const [avatar, setAvatar] = useState<string | null>(null); // 아바타 URL 상태
  const [tweets, setTweets] = useState<ITweet[]>([]); // 트윗 리스트 상태
  const [friends, setFriends] = useState<IFriend[]>([]); // 친구 리스트 상태
  const [isFriend, setIsFriend] = useState<boolean>(false); // 친구 여부 상태
  const [showFriendsList, setShowFriendsList] = useState<boolean>(false); // 친구 리스트 표시 상태

  // 아바타 변경 함수
  const onAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    if (userId !== user?.uid) return; // 현재 사용자만 아바타 변경 가능
    const { files } = e.target;
    if (files && files.length === 1) {
      const file = files[0];
      const locationRef = ref(storage, `/avatars/${userId}`);
      const result = await uploadBytes(locationRef, file);
      const avatarUrl = await getDownloadURL(result.ref);
      setAvatar(avatarUrl);
      if (user) {
        await updateProfile(user, {
          photoURL: avatarUrl,
        });
        // Firestore에 photoURL 업데이트
        if (userId) {
          const userRef = doc(db, "users", userId);
          await setDoc(userRef, { photoURL: avatarUrl }, { merge: true });
        }
      }
    }
  };

  // 프로필 정보 가져오기
  const fetchProfile = () => {
    if (!userId) return;
    const userRef = doc(db, "users", userId);
    onSnapshot(userRef, (userSnap) => {
      if (userSnap.exists()) {
        const userData = userSnap.data();
        console.log("User Data:", userData);
        setUsername(userData.displayName || userData.username || null); // null로 설정하여 조건을 쉽게 만듦
        setLastValue(userData.displayName || userData.username || "Anonymous");

        // Firebase Storage에서 사용자 ID를 파일명으로 하는 프로필 이미지 URL 가져오기
        const avatarRef = ref(storage, `/avatars/${userId}`);
        getDownloadURL(avatarRef)
          .then((avatarUrl) => {
            setAvatar(avatarUrl);
          })
          .catch((error) => {
            console.error("Error fetching avatar URL:", error);
            setAvatar(null); // 오류가 발생하면 기본 이미지를 설정할 수 있습니다.
          });
      } else {
        console.log("No such user!");
      }
    });
  };

  // 트윗 정보 가져오기
  const fetchTweets = async () => {
    if (!userId) return;
    const tweetQuery = query(
      collection(db, "tweets"),
      where("userId", "==", userId),
      orderBy("createdAt", "desc"),
      limit(25)
    );
    const snapshot = await getDocs(tweetQuery);
    const tweets = await Promise.all(
      snapshot.docs.map(async (doc) => {
        const { tweet, createdAt, userId, username, photos } = doc.data();
        const profileImage = await getProfileImage(userId);
        return {
          tweet,
          createdAt: createdAt instanceof Timestamp ? createdAt : Timestamp.fromDate(new Date(createdAt)),
          userId,
          username,
          photos,
          id: doc.id,
          profileImage,
        };
      })
    );
    setTweets(tweets);
  };

  // 친구 정보 가져오기
  const fetchFriends = async () => {
    if (!userId) return;
    const friendsRef = collection(db, "friends");
    const q = query(friendsRef, where("userId", "==", userId));
    const querySnapshot = await getDocs(q);
    const friendsList: IFriend[] = [];
    for (const friendDoc of querySnapshot.docs) {
      const friendData = friendDoc.data();
      const friendUserId = friendData.friendId;
      const friendUserRef = doc(db, "users", friendUserId);
      const friendUserSnap = await getDoc(friendUserRef);
      if (friendUserSnap.exists()) {
        const friendUserData = friendUserSnap.data();
        friendsList.push({
          userId: friendUserId,
          username: friendUserData.displayName || friendUserData.username || "Anonymous",
        });
      }
    }
    setFriends(friendsList);
    setIsFriend(friendsList.some((friend) => friend.userId === auth.currentUser?.uid));
  };

  // 친구 추가 함수
  const addFriend = async () => {
    if (!userId) return;
    const friendsRef = collection(db, "friends");
    await setDoc(doc(friendsRef), {
      userId,
      friendId: auth.currentUser?.uid,
    });
    fetchFriends(); // 친구 목록을 다시 불러옵니다.
  };

  // 친구 제거 함수
  const removeFriend = async () => {
    if (!userId) return;
    const friendsRef = collection(db, "friends");
    const q = query(friendsRef, where("userId", "==", userId), where("friendId", "==", auth.currentUser?.uid));
    const querySnapshot = await getDocs(q);
    for (const friendDoc of querySnapshot.docs) {
      await deleteDoc(friendDoc.ref);
    }
    fetchFriends(); // 친구 목록을 다시 불러옵니다.
  };

  // 프로필 이미지 가져오기
  const getProfileImage = async (userId: string) => {
    try {
      return await getDownloadURL(ref(storage, `/avatars/${userId}`));
    } catch {
      return "";
    }
  };

  // 트윗 삭제 함수
  const onDeleteTweet = async (tweetId: string) => {
    const ok = confirm("포스트를 삭제하시겠습니까?");
    if (!ok || !auth.currentUser) return;
    try {
      await deleteDoc(doc(db, "tweets", tweetId));
      setTweets(tweets.filter((tweet) => tweet.id !== tweetId));
    } catch (error) {
      console.error("Error deleting tweet:", error);
    }
  };

  // 이름 입력 변경 핸들러
  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setUsername(e.target.value);
  };

  // 편집 모드 활성화 함수
  const onEdit = () => {
    setEditMode(true);
  };

  // 편집 취소 함수
  const onCancel = () => {
    setUsername(lastValue);
    setEditMode(false);
  };

  // 이름 저장 함수
  const onSave = async () => {
    if (nameLoading || !username || !user) return;
    try {
      setNameLoading(true);
      const auth = getAuth();
      const currentUser = auth.currentUser;
      if (currentUser) {
        await updateProfile(currentUser, { displayName: username });

        // Firestore에 displayName 업데이트
        if (userId) {
          const userRef = doc(db, "users", userId);
          await updateDoc(userRef, { displayName: username, username: username });
        }
      }
    } catch (e) {
      console.error(e);
    } finally {
      setNameLoading(false);
      setEditMode(false);
      setLastValue(username);
    }
  };

  // 컴포넌트가 마운트될 때 프로필, 트윗 및 친구 목록을 가져옴
  useEffect(() => {
    if (userId) {
      fetchProfile();
      fetchTweets();
      fetchFriends();
    }
  }, [userId]);

  return (
    <Wrapper>
      <AvatarUpload htmlFor="avatar" disabled={userId !== user?.uid}>
        {avatar ? (
          <AvatarImg src={avatar} />
        ) : (
          <svg
            fill="currentColor"
            viewBox="0 0 20 20"
            xmlns="http://www.w3.org/2000/svg"
            aria-hidden="true"
          >
            <path d="M10 8a3 3 0 100-6 3 3 0 000 6zM3.465 14.493a1.23 1.23 0 00.41 1.412A9.957 9.957 0 0010 18c2.31 0 4.438-.784 6.131-2.1.43-.333.604-.903.408-1.41a7.002 7.002 0 00-13.074.003z" />
          </svg>
        )}
      </AvatarUpload>
      <AvatarInput onChange={onAvatarChange} id="avatar" type="file" accept="image/*" />
      <Name>
        {editMode ? (
          <Input value={username ?? ""} onChange={onChange} placeholder="새로운 이름을 입력해 주세요." required />
        ) : (
          <>
            {username ?? "Anonymous"}
            {userId === user?.uid && (
              <EditButton onClick={onEdit}>
                <svg
                  fill="none"
                  strokeWidth={1}
                  stroke="currentColor"
                  viewBox="0 0 24 24"
                  xmlns="http://www.w3.org/2000/svg"
                  aria-hidden="true"
                >
                  <path
                    strokeLinecap="round"
                    strokeLinejoin="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"
                  />
                </svg>
              </EditButton>
            )}
          </>
        )}
        {editMode && (
          <>
            <Button $isRed={true} onClick={onCancel}>
              취소
            </Button>
            <Button onClick={onSave}>{nameLoading ? "저장 중.." : "저장"}</Button>
          </>
        )}
      </Name>
      {username && avatar && userId !== user?.uid && (
        <Button onClick={isFriend ? removeFriend : addFriend}>
          {isFriend ? "언팔로우" : "팔로우"}
        </Button>
      )}
      <FriendsListButton onClick={() => setShowFriendsList((prev) => !prev)}>
        팔로워 리스트
      </FriendsListButton>
      <Modal
        isOpen={showFriendsList}
        onRequestClose={() => setShowFriendsList(false)}
        contentLabel="Friends List"
        style={customStyles}
      >
        {friends.length > 0 ? (
          <ul>
            {friends.map((friend) => (
              <li key={friend.userId}>{friend.username}</li>
            ))}
          </ul>
        ) : (
          <p>팔로워 목록이 비어 있습니다.</p>
        )}
        <CloseButton onClick={() => setShowFriendsList(false)}>X</CloseButton>
      </Modal>
      <Tweets>
        {tweets.map((tweet) => (
          <Tweet
            key={tweet.id}
            id={tweet.id}
            photos={tweet.photos || []}
            tweet={tweet.tweet}
            userId={tweet.userId}
            username={username || tweet.username} // 업데이트된 username을 사용
            createdAt={tweet.createdAt}
            profileImage={tweet.profileImage || ""}
            onDeleteTweet={onDeleteTweet}
          />
        ))}
      </Tweets>
    </Wrapper>
  );
};

export default Profile;

 

위 코드는 사용자 프로필 페이지를 구현한 React 컴포넌트입니다. 주요 기능은 다음과 같습니다:

  1. 프로필 정보 및 트윗 불러오기: fetchProfile, fetchTweets, fetchFriends 함수를 사용하여 Firestore에서 사용자 프로필 정보, 트윗 및 친구 목록을 가져옵니다.
  2. 프로필 수정: 사용자가 자신의 프로필 사진과 이름을 변경할 수 있습니다. onAvatarChange, onEdit, onCancel, onSave 함수가 관련 작업을 수행합니다.
  3. 팔로우/언팔로우 기능: 다른 사용자를 팔로우하거나 언팔로우할 수 있습니다. addFriend, removeFriend 함수가 이를 처리합니다.
  4. 트윗 삭제: 사용자가 자신의 트윗을 삭제할 수 있습니다. onDeleteTweet 함수가 이를 처리합니다.
  5. 모달 창: 친구 목록을 모달 창으로 표시합니다. Modal 라이브러리를 사용하여 구현되었습니다.

어려웠던 부분 상세 설명

const AvatarUpload = styled.label<{ disabled?: boolean }>

 

<{ disabled?: boolean }>: 이 부분은 TypeScript의 제네릭을 사용하여 styled.label 컴포넌트가 받을 수 있는 props의 타입을 정의합니다

  • disabled?: boolean: disabled prop은 선택적(optional) prop으로, boolean 타입입니다. 이 prop이 전달되면 true 또는 false 값을 가질 수 있으며, 전달되지 않으면 undefined일 수 있습니다.

 

background-color: ${(props) => (props.$isRed ? "#F5957F" : "#9D7FF5")};

 

(props) => (props.$isRed ? "#F5957F" : "#9D7FF5")

  • 이 부분은 템플릿 리터럴 안에서 JavaScript 표현식을 사용하여 동적으로 값을 설정하는 것입니다.

(props) => (props.$isRed ? "#F5957F" : "#9D7FF5")

  • props: 컴포넌트가 받은 props 객체입니다.
  • props.$isRed: $isRed라는 boolean 타입의 prop을 확인합니다. 여기서 $ 기호는 단순히 prop 이름의 일부로, 특별한 의미는 없습니다. 하지만 가독성을 위해 프레임워크나 팀에 따라 사용하는 관습일 수 있습니다.
  • isRed는 특정 조건에 따라 스타일이나 동작을 제어하기 위해 사용되는 boolean 타입의 prop입니다.(true, false) 이 prop의 값에 따라 컴포넌트의 스타일이 동적으로 변경될 수 있습니다.

Q. interface? 

  • 객체의 스펙(속성과 속성의 타입)
  • 함수의 파라미터
  • 함수의 스펙(파라미터, 반환 타입 등)
  • 배열과 객체를 접근하는 방식
  • 클래스

Q. null > (null)?

= 0 = false

 

Q. desc?

설명 문자열을 의미한다. 객체의 속성이 되기도 합니다.

 

Q. Promise?

비동기 작업을 처리하고, 해당 작업이 성공했는지 실패했는지를 나타내는 객체입니다. JavaScript의 Promise와 동일하게 작동하지만, TypeScript에서는 추가적으로 타입을 명시할 수 있어 더욱 안전하게 비동기 작업을 처리할 수 있습니다.

 

Q. snapshot?

NoSQL인 firebase에선 특정 시점의 데이터 상태를 나타내는 객체입니다. 

 

onSnapshot 함수:

  • onSnapshot 함수는 Firestore 컬렉션의 변경 사항을 실시간으로 수신합니다.
  • 첫 번째 인자로 컬렉션 참조를 받고, 두 번째 인자로 QuerySnapshot을 인자로 받는 콜백 함수를 사용합니다.
  • 콜백 함수 내부에서 snapshot.docs.map을 사용하여 문서를 User 타입으로 변환하고, 이를 출력합니다.

Q. 모달이란?

 

모달(Modal)은 웹 애플리케이션 및 사용자 인터페이스 디자인에서 많이 사용되는 UI 요소로, 현재 화면 위에 겹쳐서 표시되는 창입니다. 모달은 보통 사용자의 주의를 끌거나 중요한 정보를 전달하기 위해 사용되며, 사용자가 모달을 닫기 전까지는 다른 상호작용을 할 수 없도록 하는 것이 일반적입니다.

 

 

728x90
반응형