Skip to content

과거에 개발한 'PeaNutter'를 마이그레이션하고, 좋아요, 팔로우, 해시태그 및 검색, 다국어 처리 등의 새로운 기능을 추가한 프로젝트 입니다.

Notifications You must be signed in to change notification settings

Stilllee/peanutter

Repository files navigation

PeaNutter V2

PeaNutter


목차

  1. 프로젝트 정보
  2. 기술 스택
  3. 주요 기능 및 특징
  4. 페이지별 상세 기능
  5. 반응형 웹 디자인
  6. 설치 및 실행
  7. 배포
  8. 폴더 구조

프로젝트 정보

이 프로젝트는 과거에 개발한 소셜미디어 'PeaNutter'를 마이그레이션하고, 좋아요, 팔로우, 해시태그 및 검색, 다국어 처리 등의 새로운 기능을 추가한 프로젝트 입니다.

마이그레이션 과정과 트러블 슈팅에 대한 내용은 여기에서 확인하실 수 있습니다.

프로젝트 개요

  • 주제 : PeaNutter의 마이그레이션 및 기능 확장
  • 작업 기간 : 2024.03.23 ~ 2024.04.29

기술 스택

Vite React TypeScript Sass Recoil Firebase

주요 기능 및 특징

1. 좋아요

1-1 좋아요 토글 기능

하트 아이콘을 클릭하면, 현재 사용자의 ID가 해당 게시물의 'likes'배열에 추가되며, 이미 좋아요를 누른 게시물을 다시 클릭하면, 해당 사용자의 ID가 'likes'배열에서 제거됩니다.

이 과정은 Firestore의 arrayUnionarrayRemove 메서드를 사용하여 구현하였으며, 실시간 업데이트를 통해 좋아요 수를 즉시 반영합니다.

코드
// components/posts/PostBox.tsx

const toggleLike = async () => {
  const postRef = doc(db, "posts", post.id); // 해당 게시물의 문서 참조

  if (user?.uid && post.likes?.includes(user.uid)) {
    // 현재 사용자가 이미 좋아요를 누른 경우
    await updateDoc(postRef, {
      likes: arrayRemove(user?.uid), // likes 배열에서 사용자 ID 제거
      likeCount: post?.likeCount ? post?.likeCount - 1 : 0, // 좋아요 수 감소
    });
  } else {
    // 현재 사용자가 좋아요를 누르지 않은 경우
    await updateDoc(postRef, {
      likes: arrayUnion(user?.uid), // likes 배열에 사용자 ID 추가
      likeCount: post?.likeCount ? post?.likeCount + 1 : 1, // 좋아요 수 증가
    });
  }
};

1-2 좋아요한 게시물 목록

사용자는 '좋아요'를 누른 게시물만 모아 별도의 탭에서 확인할 수 있습니다.

코드
useEffect(() => {
  if (user) {
    const postsRef = collection(db, "posts"); // 게시물 컬렉션 참조

    // 좋아요한 게시물만 조회하는 쿼리 생성
    const likePostQuery = query(
      postsRef,
      where("likes", "array-contains", user.uid),
      orderBy("createdAt", "desc")
    );

    // 쿼리 결과를 실시간으로 업데이트
    onSnapshot(likePostQuery, (snapshot) => {
      const dataObj = snapshot.docs.map((doc) => ({
        ...doc.data(),
        id: doc.id,
      }));
      setLikePosts(dataObj as PostProps[]); // 좋아요한 게시물 목록 업데이트
    });
  }
}, [user]);

1-3 사용자 인터페이스

마음에 들어요

코드
// components/posts/PostBox.tsx

return (
  <button onClick={toggleLike}>
    // 좋아요 클릭 여부에 따라 아이콘 변경
    {user && post?.likes?.includes(user.uid) ? <FaHeart /> : <FaRegHeart />}
    {post?.likeCount || 0} // 좋아요 수 표시
  </button>
);
<>
  {activeTab === "like" && (
    <div>
      // 좋아요한 게시물 목록 표시
      {likePosts?.length > 0 ? (
        likePosts?.map((post) => <PostBox post={post} key={post?.id} />)
      ) : (
        <div>
          <div>{translate("NO_POSTS")}</div>
        </div>
      )}
    </div>
  )}
</>

2. 팔로우

사용자는 다른 사용자를 팔로우할 수 있고, 팔로우한 사용자의 게시물을 '팔로잉' 탭에서 볼 수 있습니다. 또한, 언제든지 팔로우를 취소할 수 있습니다.

2-1 팔로우 상태 관리

FollowingBox 컴포넌트에서는 사용자가 현재 게시물의 작성자를 팔로우하고 있는지 여부를 확인하고, 팔로우 또는 언팔로우 할 수 있는 기능을 제공합니다.

코드
// components/following/FollowingBox.tsx

const onClickFollow = async (e: React.MouseEvent<HTMLButtonElement>) => {
  if (user?.uid) {
    const followingRef = doc(db, "following", user.uid); // 현재 사용자의 팔로잉 목록 문서 참조
    // 현재 사용자의 팔로잉 목록에 게시물 작성자 ID 추가
    await setDoc(
      followingRef,
      {
        users: arrayUnion({ id: post.uid }),
      },
      { merge: true }
    );

    const followerRef = doc(db, "follower", post.uid); // 게시물 작성자의 팔로워 목록 문서 참조
    // 게시물 작성자의 팔로워 목록에 현재 사용자의 ID 추가
    await setDoc(
      followerRef,
      {
        users: arrayUnion({ id: user.uid }),
      },
      { merge: true }
    );
    // ...
  }
};

const onClickDeleteFollow = async (e: React.MouseEvent<HTMLButtonElement>) => {
  if (user?.uid) {
    const followingRef = doc(db, "following", user.uid);
    // 현재 사용자의 팔로잉 목록에서 게시물 작성자 ID 제거
    await updateDoc(followingRef, {
      users: arrayRemove({ id: post.uid }),
    });

    const followerRef = doc(db, "follower", post.uid);
    // 게시물 작성자의 팔로워 목록에서 현재 사용자의 ID 제거
    await updateDoc(followerRef, {
      users: arrayRemove({ id: user.uid }),
    });
  }
};

2-2 팔로우 목록 갱신

팔로워 목록은 실시간으로 업데이트되며, 사용자가 팔로우 또는 언팔로우 액션을 취할 때 데이터베이스의 변경 사항을 즉각 반영합니다. 이는 Firestore의 실시간 리스너인 onSnapshot을 사용하여 구현하였습니다.

코드
// components/following/FollowingBox.tsx

const getFollowers = useCallback(async () => {
  if (post.uid) {
    const ref = doc(db, "follower", post.uid); // 게시물 작성자의 팔로워 목록 문서 참조
    // 문서 스냅샷을 통해 실시간으로 변경 사항 반영
    onSnapshot(ref, (doc) => {
      setPostFollowers([]); // 팔로워 목록 초기화
      doc?.data()?.users.map(
        (
          user: UserProps // 데이터에서 사용자 목록 추출
        ) => setPostFollowers((prev) => (prev ? [...prev, user.id] : [])) // 팔로워 ID로 목록 업데이트
      );
    });
  }
}, [post.uid]);

2-3 팔로우 게시물 필터링

'팔로잉'탭에서는 사용자가 팔로우하는 계정의 게시물만 조회하여 표시하며, 이는 followingIds배열을 사용하여 Firestore 쿼리를 필터링하여 구현하였습니다.

코드
사용자 팔로우 목록 조회
// pages/home/Home.tsx

const [followingIds, setFollowingIds] = useState<string[]>([""]);

const getFollowingIds = useCallback(async () => {
  if (user?.uid) {
    const ref = doc(db, "following", user.uid); // 현재 사용자의 팔로잉 목록 문서 참조

    // 문서 스냅샷을 통해 실시간으로 변경 사항 반영
    onSnapshot(ref, (doc) => {
      setFollowingIds([]); // 팔로잉 ID 목록 초기화
      doc?.data()?.users?.map(
        (user: UserProps) =>
          setFollowingIds((prev) => (prev ? [...prev, user.id] : [])) // 팔로잉 ID 목록 업데이트
      );
    });
  }
}, [user?.uid]); // 현재 사용자 ID가 변경될 때마다 호출
팔로우한 사용자의 게시물 필터링
// pages/home/Home.tsx

const [followingPosts, setFollowingPosts] = useState<PostProps[]>([]);

useEffect(() => {
  if (user) {
    const postsRef = collection(db, "posts"); // 게시물 컬렉션 참조
    //...
    if (followingIds.length > 0) {
      // Firestore 쿼리를 생성하여 팔로우하는 사용자의 게시물만 조회
      const followingQuery = query(
        postsRef,
        where("uid", "in", followingIds), // uid 필드가 followingIds 배열에 포함된 문서만 선택
        orderBy("createdAt", "desc")
      );

      // 쿼리 결과를 실시간으로 업데이트
      onSnapshot(followingQuery, (snapshot) => {
        const dataObj = snapshot.docs.map((doc) => ({
          ...doc.data(), // 문서 데이터 추출
          id: doc.id, // 문서 ID 포함
        }));
        setFollowingPosts(dataObj as PostProps[]); // 팔로잉 게시물 목록 업데이트
      });
    } else {
      setFollowingPosts([]); // 팔로잉하는 계정이 없는 경우 빈 배열 반환
    }
  }
}, [followingIds, user]);

2-4 사용자 인터페이스

팔로우

코드
// components/following/FollowingBox.tsx

return (
  <>
    {user &&
      user?.uid !== post.uid && // 현재 사용자가 게시물 작성자가 아닌 경우에만 팔로우 버튼 표시
      (postFollowers.includes(user?.uid) ? ( // 팔로워 목록에 현재 사용자 ID가 있는 경우
        <button onClick={onClickDeleteFollow}>
          {translate("BUTTON_UNFOLLOW")} // 언팔로우 버튼
        </button>
      ) : (
        <button onClick={onClickFollow}>
          {translate("BUTTON_FOLLOW")} // 팔로우 버튼
        </button>
      ))}
  </>
);
// pages/home/Home.tsx

{
  activeTab === "following" && ( // 팔로잉 탭이 활성화된 경우
    <div>
      // 팔로잉 계정의 게시물만 표시
      {followingPosts?.length > 0 ? (
        // 팔로잉 게시물이 있는 경우 목록 표시
        followingPosts?.map((post) => <PostBox post={post} key={post?.id} />)
      ) : (
        <div>
          <div>{translate("NO_POSTS")}</div>
        </div>
      )}
    </div>
  );
}

3. 해시태그 및 검색

사용자는 게시물을 작성할 때 해시태그를 추가할 수 있으며, 검색 페이지에서 해시태그를 검색하여 관련 게시물을 조회할 수 있습니다.

3-1 해시태그 추가 및 제거

스페이스바를 입력하면 해시태그가 추가되며, 중복된 해시태그는 추가되지 않습니다. 해시태그를 클릭하면 해당 해시태그는 삭제됩니다.

코드
// components/posts/PostForm.tsx

const [hashTag, setHashTag] = useState<string>("");
const [tags, setTags] = useState<string[]>([]);

const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
  const target = e.target as HTMLInputElement;

  // 공백 입력 시 해시태그 추가
  if (e.key === " " && target?.value.trim() !== "") {
    if (tags?.includes(target?.value.trim())) {
      // 중복 해시태그 방지
      toast(translate("TOAST_ALREADY_HASHTAG"));
    } else {
      // 중복이 아닌 경우 해시태그 추가
      setTags((prev) => (prev?.length > 0 ? [...prev, hashTag] : [hashTag]));
      setHashTag("");
    }
  }
};

const removeTag = (tag: string) => {
  setTags((prev) => prev?.filter((value) => value !== tag));
};

const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
  // ...
  // 게시글 작성 로직
  await addDoc(collection(db, "posts"), {
    content,
    createdAt: Timestamp.now(),
    username: user?.displayName || "Anonymous",
    uid: user?.uid,
    email: user?.email,
    hashTags: tags, // 해시태그 배열 추가
    imageUrl,
  });
  // ...
};

3-2 게시물 검색

검색 페이지에서는 사용자가 입력한 해시태그를 포함하는 게시글만 조회하여 표시합니다.

코드
// pages/search/Search.tsx

const [tagQuery, setTagQuery] = useState<string>("");

const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  setTagQuery(e.target.value.trim()); // 검색 쿼리 업데이트
};

useEffect(() => {
  if (user) {
    const postRef = collection(db, "posts"); // 게시물 컬렉션 참조
    const postQuery = query(
      postRef,
      where("hashTags", "array-contains-any", [tagQuery]), // 해시태그 배열에 검색 쿼리가 포함된 문서만 선택
      orderBy("createdAt", "desc")
    );

    // 쿼리 결과를 실시간으로 업데이트
    onSnapshot(postQuery, (snapshot) => {
      const dataObj = snapshot.docs.map((doc) => ({
        ...doc.data(),
        id: doc.id,
      }));

      setPosts(dataObj as PostProps[]); // 검색된 게시물 목록 업데이트
    });
  }
}, [tagQuery, user]);

3-3 사용자 인터페이스

해시태그 및 검색

코드
// components/search/Search.tsx

return (
  <>
    <div>
      // 해시태그로 검색된 게시물 목록 표시
      {posts?.length > 0 ? (
        posts.map((post) => <PostBox post={post} key={post?.id} />)
      ) : (
        <div>
          <div>{translate("NO_POSTS")}</div>
        </div>
      )}
    </div>
  </>
);

4. 알림

알림 페이지에서는 자신의 게시물에 대한 답글, 팔로우 알림을 확인할 수 있습니다.

4-1 알림 시스템 구현

Notifications컴포넌트는 사용자의 알림 목록을 조회하고, 실시간으로 업데이트 합니다.

코드
// pages/notifications/Notifications.tsx

const [notifications, setNotifications] = useState<NotificationsProps[]>([]);

useEffect(() => {
  if (user) {
    const ref = collection(db, "notifications"); // 알림 컬렉션 참조
    // 사용자 UID에 따른 알림을 최신순으로 정렬하는 쿼리 생성
    const notificationQuery = query(
      ref,
      where("uid", "==", user.uid), // 현재 사용자의 UID와 일치하는 알림만 조회
      orderBy("createdAt", "desc")
    );

    // 쿼리 결과에 대한 실시간 리스너 설정
    onSnapshot(notificationQuery, (snapshot) => {
      const dataObj = snapshot.docs.map((doc) => ({
        ...doc.data(),
        id: doc.id,
      }));

      setNotifications(dataObj as NotificationsProps[]); // 상태 업데이트
    });
  }
}, [user]);

4-2 알림 상호작용

사용자가 알림을 클릭하면 알림은 '읽음'상태로 표시되며, 답글 알림의 경우 해당 게시물로 이동합니다.

코드
// pages/notifications/NotificationBox.tsx

const nav = useNavigate();

const onClickNotification = async (url: string) => {
  const ref = doc(db, "notifications", notification.id); // 클릭된 알림 문서 참조
  await updateDoc(ref, {
    isRead: true, // 알림 상태를 '읽음'으로 변경
  });

  nav(url); // 제공된 URL로 라우팅
};

4-3 사용자 인터페이스

알림

코드
// pages/notifications/Notifications.tsx

return (
  <>
    <div className="post">
      // 알림 목록 표시
      {notifications.length > 0 ? (
        notifications.map((noti) => (
          <NotificationBox notification={noti} key={noti.id} />
        ))
      ) : (
        <div className="post__no-posts">
          <div className="post__text">{translate("NO_NOTIFICATIONS")}</div>
        </div>
      )}
    </div>
  </>
);
// pages/notifications/NotificationBox.tsx

return (
  <>
    <div onClick={() => onClickNotification(notification.url)}>
      <div>
        <div>{formattedDate}</div>
        {notification.isRead === false && <div />} // 읽지 않은 알림 표시
      </div>
      <div>
        // 설정된 언어에 따라 알림 내용 표시
        {lang === "en" ? notification.content.en : notification.content.ko}
      </div>
    </div>
  </>
);

5. 다국어 처리

한국어와 영어를 지원하여 사용자가 두 언어로 서비스를 이용할 수 있도록 개선하였습니다.

5-1 기능 구현

텍스트 정의

TRANSLATIONS 객체에 각 UI 요소에 대한 한국어와 영어 텍스트를 정의했습니다.

코드
// constants/language.ts

const TRANSLATIONS = {
  // ...
  TAB_MY: {
    ko: "내 게시물",
    en: "My Nuts",
  },
  TAB_LIKE: {
    ko: "마음에 들어요",
    en: "Likes",
  },
  //...
};
언어 상태 관리

Recoil을 활용하여 애플리케이션의 언어 상태를 전역적으로 관리했습니다. 사용자가 언어를 변경할 때마다 상태가 업데이트되어 앱 전체에 반영됩니다.

코드
// atom/index.tsx

import { atom } from "recoil";

export type LanguageType = "en" | "ko";

export const languageState = atom<LanguageType>({
  key: "language", // 상태의 고유 키
  default: (localStorage.getItem("language") as LanguageType) || "ko", // 기본 언어 설정
});
언어 변경 기능

커스텀 훅 useTranslation을 사용하여 컴포넌트에서 현재 언어에 맞는 텍스트를 조회할 수 있습니다. 이 훅은 languageState를 참조하여 필요한 텍스트를 반환합니다.

코드
// hooks/useTranslation.tsx

import { useRecoilValue } from "recoil";
import { languageState } from "atom/index";
import TRANSLATIONS from "constants/language";

export default function useTranslation() {
  const lang = useRecoilValue(languageState); // 현재 언어 상태

  return (key: keyof typeof TRANSLATIONS) => {
    return TRANSLATIONS[key][lang]; // 요청된 키에 해당하는 현재 언어의 텍스트 반환
  };
}

5-2 사용자 인터페이스

Profile 컴포넌트에서 다국어 처리 기능을 실제로 적용하는 방법입니다. 사용자가 언어를 변경하면 페이지 내의 UI 텍스트가 즉시 업데이트됩니다.

다국어 처리

코드
// pages/profile/Profile.tsx
import { languageState } from "atom/index";
import useTranslation from "hooks/useTranslation";

export default function Profile() {
  const [language, setLanguage] = useRecoilState(languageState);
  const translate = useTranslation();

  const onClickLanguage = () => {
    setLanguage(language === "en" ? "ko" : "en");
    localStorage.setItem("language", language === "en" ? "ko" : "en");
  };

  return (
    // ...
    <button onClick={onClickLanguage}>
      {language === "en" ? "English" : "한국어"}
    </button>
    // ...
      <span>{translate("TAB_MY")}</span>
      <span>{translate("TAB_LIKE")}</span>
    // ...
  );
}

페이지별 상세 기능

랜딩 페이지

로그인 및 회원가입 페이지로 이동할 수 있는 랜딩 페이지입니다. 사용자 인증을 Firebase Auth으로 구현하였습니다.

소셜 로그인 회원가입
소셜 로그인 회원가입
로그인 비밀번호 찾기
로그인 비밀번호 찾기

홈 페이지

게시글 목록 조회와 게시글 작성 기능을 제공하는 메인 페이지 입니다.

홈 - 게시글 작성

  • 전체 목록을 기본으로 보여주며, 상단의 탭을 통해 팔로우 중인 계정의 게시물만 별도로 조회할 수 있습니다.

  • 각 게시물을 통해 좋아요팔로우 기능을 사용할 수 있고, 게시물 클릭시 상세 페이지로 이동합니다.


게시글 상세 페이지

게시글의 내용과 답글 기능을 제공하는 페이지입니다. 작성자에 한해 수정 또는 삭제 기능을 사용할 수 있습니다.

수정 답글 삭제
게시글 수정 답글 삭제

검색 페이지

해시태그로 관련 게시물을 검색할 수 있는 페이지입니다.

검색


알림 페이지

팔로우, 답글 등 사용자의 알림 목록을 조회할 수 있는 페이지입니다.

알림


프로필 페이지

사용자와 관련된 정보를 조회하고 수정할 수 있는 페이지입니다.

프로필

  • 프로필 수정 버튼을 통해 사용자의 프로필 이미지와 닉네임을 수정할 수 있습니다.
  • 우측 상단의 언어 변경 버튼을 통해 언어를 변경할 수 있습니다.
  • 두 개의 탭을 통해 사용자가 작성한 게시물과 좋아요를 누른 게시물을 조회할 수 있습니다.


반응형 웹 디자인

Sass를 사용하여 반응형 웹 디자인을 구현하였으며 미디어 쿼리로 모바일, 태블릿, 데스크탑 화면에 따라 레이아웃이 변경되도록 하였습니다.

로그인 전

로그인 후

미디어 쿼리
// utils.scss

// 뷰 포인트
$mobile: 360px;
$tablet: 501px;
$desktop: 1024px;

// 미디어 쿼리
@mixin xsMobile {
  @media (max-width: ($mobile - 1)) {
    @content;
  }
}

@mixin mobile {
  @media (min-width: $mobile) and (max-width: ($tablet - 1)) {
    @content;
  }
}

@mixin tablet {
  @media (min-width: $tablet) and (max-width: ($desktop - 1)) {
    @content;
  }
}

@mixin desktop {
  @media (min-width: $desktop) {
    @content;
  }
}

설치 및 실행

npm install
npm run dev

배포

Vercel

PeaNutter


폴더 구조

.
├── README.md
├── index.html
├── package-lock.json
├── package.json
├── public
│   └── profile.webp
├── src
│   ├── App.tsx
│   ├── _utils.scss     # 유틸리티 스타일
│   ├── assets
│   │   └── logo.svg
│   ├── atom            # Recoil atom 디렉토리
│   │   └── index.tsx
│   ├── components      # 컴포넌트 디렉토리
│   │   ├── Header.tsx        # 헤더 컴포넌트
│   │   ├── Layout.tsx        # 레이아웃 컴포넌트
│   │   ├── Loader.tsx        # 로딩 컴포넌트
│   │   ├── Menu.tsx          # 메뉴 컴포넌트
│   │   ├── MobileHeader.tsx  # 모바일 헤더 컴포넌트
│   │   ├── Router.tsx        # 라우터 컴포넌트
│   │   ├── comments          # 답글 관련 컴포넌트
│   │   │   ├── CommentBox.module.scss
│   │   │   ├── CommentBox.tsx
│   │   │   └── CommentForm.tsx
│   │   ├── following         # 팔로잉 관련 컴포넌트
│   │   │   └── FollowingBox.tsx
│   │   ├── landing           # 랜딩 페이지 관련 컴포넌트
│   │   │   ├── LocalSign.tsx
│   │   │   └── SocialLogin.tsx
│   │   ├── posts             # 게시물 관련 컴포넌트
│   │   │   ├── PostBox.tsx
│   │   │   ├── PostEditForm.tsx
│   │   │   └── PostForm.tsx
│   │   └── users             # 계정 관련 컴포넌트
│   │       ├── LoginForm.tsx
│   │       ├── ResetPasswordForm.tsx
│   │       └── SignupForm.tsx
│   ├── constants
│   │   ├── defaultProfileImage.ts
│   │   └── language.ts
│   ├── context
│   │   └── AuthContext.tsx
│   ├── firebaseApp.ts
│   ├── hooks
│   │   └── useTranslation.tsx
│   ├── index.scss
│   ├── main.tsx
│   ├── pages           # 페이지별 컴포넌트
│   │   ├── home              # 메인 페이지
│   │   │   └── Home.tsx
│   │   ├── landing           # 랜딩 페이지
│   │   │   └── Landing.tsx
│   │   ├── notifications     # 알림 페이지
│   │   │   ├── NotificationBox.module.scss
│   │   │   ├── NotificationBox.tsx
│   │   │   └── Notifications.tsx
│   │   ├── posts             # 게시물 페이지
│   │   │   ├── PostDetail.tsx
│   │   │   ├── PostEdit.tsx
│   │   │   ├── PostList.tsx
│   │   │   └── PostNew.tsx
│   │   ├── profile           # 프로필 페이지
│   │   │   ├── Profile.tsx
│   │   │   └── ProfileEdit.tsx
│   │   ├── search            # 검색 페이지
│   │   │   └── Search.tsx
│   │   └── users             # 계정 관련 페이지
│   │       ├── Login.tsx
│   │       ├── ResetPassword.tsx
│   │       └── Signup.tsx
│   └── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
├── vercel.json
└── vite.config.ts

About

과거에 개발한 'PeaNutter'를 마이그레이션하고, 좋아요, 팔로우, 해시태그 및 검색, 다국어 처리 등의 새로운 기능을 추가한 프로젝트 입니다.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages