트위터 클론 코딩 - 홈

김재한·2024년 1월 31일
0

트위터 클론코딩

목록 보기
6/6

홈 화면 구성

로그인에 성공하면 가장 처음 보여지는 화면이다. 크게 3개의 섹션으로 나누어져 있으며, 좌측에서 메뉴 변경을 할 경우 중앙 섹션만 변경되는 구조이다.

홈 화면

// (afterLogin)/layout.tsx
type Props = {children: ReactNode, modal: ReactNode}

export default async function AfterLoginLayout({children, modal}:Props){
    const session = await auth();

    return (
        <div className={style.container}>
            <RQProvider>
                {/* Left Section */}
                <header className={style.leftSectionWrapper}>
                    <section className={style.leftSection}>
                        <div className={style.leftSectionFixed}>
                            <Link className={style.logo} href={session?.user ? "/home": "/"}>
                                <div className={style.logoPill}>
                                    <Image src={zLogo} alt="z.com 로고" width={40} height={40}/>
                                </div>
                            </Link>
                            {
                                session?.user &&
                              <>
                                <nav>
                                  <ul>
                                    <NavMenu/>
                                  </ul>
                                  <Link href="/compose/tweet" className={style.postButton}>
                                    <span>게시하기</span>
                                    <svg viewBox="0 0 24 24" width={24} aria-hidden="true" className="r-jwli3a r-4qtqp9 r-yyyyoo r-1472mwg r-dnmrzs r-bnwqim r-1plcrui r-lrvibr r-lrsllp"><g><path d="M23 3c-6.62-.1-10.38 2.421-13.05 6.03C7.29 12.61 6 17.331 6 22h2c0-1.007.07-2.012.19-3H12c4.1 0 7.48-3.082 7.94-7.054C22.79 10.147 23.17 6.359 23 3zm-7 8h-1.5v2H16c.63-.016 1.2-.08 1.72-.188C16.95 15.24 14.68 17 12 17H8.55c.57-2.512 1.57-4.851 3-6.78 2.16-2.912 5.29-4.911 9.45-5.187C20.95 8.079 19.9 11 16 11zM4 9V6H1V4h3V1h2v3h3v2H6v3H4z"></path></g></svg>
                                  </Link>
                                </nav>
                                <LogoutButton userInfo={session}/>
                              </>
                            }
                        </div>
                    </section>
                </header>
                <div className={style.rightSectionWrapper}>
                    <div className={style.rightSectionInner}>
                        {/*Center*/}
                        <main className={style.main}>
                            {children}
                        </main>
                        {/*Right Section*/}
                        <section className={style.rightSection}>
                            {/*검색 창*/}
                            <RightSearchZone/>
                            {/*트렌드*/}
                            <TrendSection/>
                            <div className={style.followRecommend}>
                                <h3>팔로우 추천</h3>
                                <FollowRecommendSection/>
                            </div>
                        </section>
                    </div>
                </div>
                {modal}
            </RQProvider>
        </div>
    )
}

Left & Right 영역은 고정되어 있어 바뀌지 않지만, Center 부분은 {children} 으로 되어있어 메뉴 이동 시 화면이 바뀌게 되어있다.

💡RQProvider
ReactQuery Provider로 포함되어 있는 컴포넌트에서는 useQuery로 queryClient를 공유할 수 있다.

Left Section

Left Section 은 <NavMenu /> 컴포넌트와 게시하기 버튼 <LogoutButton /> 컴포넌트로 구성되어있다.

// /(afterLogin)/_component/NavMenu.tsx
"use client"
import {useSelectedLayoutSegment} from "next/navigation";
import Link from "next/link";
import style from "./navMenu.module.css"
import {useSession} from "next-auth/react";

export default function NavMenu(){
    const segment = useSelectedLayoutSegment()
    const {data: userInfo} = useSession()

    return (
        <>
            <li>
                <Link href="/home">
                    <div className={style.navPill}>
                        {segment === 'home' ?
                            <>
                                {/* 선택되어 있는 홈 아이콘 */}
                            </> :
                            <>
                                {/* 기본 홈 아이콘 */}
                            </>
                        }
                    </div>
                </Link>
            </li>
            <li>
                <Link href="/explore">
                    <div className={style.navPill}>
                        {segment && (['search', 'explore'].includes(segment)) ?
                            <>
                                {/* 선택되어 있는 탐색하기 아이콘 */}
                            </> :
                            <>
                                {/* 기본 탐색하기 아이콘 */}
                            </>
                        }
                    </div>
                </Link>
            </li>
            <li>
                <Link href="/messages">
                    <div className={style.navPill}>
                        {segment === 'messages' ?
                            <>
                                {/* 선택되어 있는 쪽지 아이콘 */}
                            </> :
                            <>
                                {/* 기본 쪽지 아이콘 */}
                            </>
                        }
                    </div>
                </Link>
            </li>
            {userInfo?.user?.email && <li>
              <Link href={`/${userInfo?.user.email}`}>
                <div className={style.navPill}>
                    {segment === userInfo?.user?.email ?
                        <>
                            {/* 선택되어 있는 메시지 아이콘 */}
                        </> :
                        <>
                            {/* 기본 메시지 아이콘 */}
                        </>
                    }
                </div>
              </Link>
            </li>}
        </>
    )
}

useSelectedLayoutSegment() 훅을 사용해 현재 페이지를 확인하고 이에 따라 메뉴 아이콘을 변경한다.

💡 useSelectedLayoutSegment()

next 에서 제공하는 훅으로 현재 위치한 메뉴를 알려준다.
1️⃣ const segment = useSelectedLayoutSegment()로 선언하면
👉🏻 /compose/tweet 에 진입 시 segment 는 compose 를 리턴 하고
2️⃣ const segment = useSelectedLayoutSegments() 로 선언하면
👉🏻 ['compose', 'tweet']을 리턴한다.

글 등록화면

게시하기 버튼 선택 시 /compse/tweet 로 이동하게 되는데, 기존 로그인 & 회원가입 처럼 Parallel & Intercepting 된다.

LogoutButton.tsx

로그아웃 컴포넌트

Right Section

Right Section 은 <RightSearchZone />, <TrendSection />, <FollowRecommendSection /> 컴포넌트로 구성되어있다.

RightSearchZone.tsx

// /(afterLogin)/_component/RightSearchZone.tsx
"use client"
import {usePathname, useRouter, useSearchParams} from "next/navigation";
import style from "@/app/(afterLogin)/_component/rightSearchZone.module.css";
import SearchForm from "@/app/(afterLogin)/_component/SearchForm";

export default function RightSearchZone(){
    const pathName = usePathname()
    const searchParams = useSearchParams();
    const router = useRouter();

    // 팔로우하는 사용자
    const onChangeFollow = () => {
        const newSearchParams = new URLSearchParams(searchParams);
        newSearchParams.set('pf', 'on')
        router.replace(`/search?${newSearchParams.toString()}`)
    }
    // 모든 사용자
    const onChangeAll = () => {
        const newSearchParams = new URLSearchParams(searchParams);
        newSearchParams.delete ('pf')
        router.replace(`/search?${newSearchParams.toString()}`)
    }

    switch (pathName) {
        case '/explore':
            return null;
        case '/search':
            return(
                <div>
                    <h5 className={style.filterTitle}>검색 필터</h5>
                    <div className={style.filterSection}>
                        <div>
                            <label>사용자</label>
                            <div className={style.radio}>
                                <div>모든 사용자</div>
                                <input type="radio" name="pf" defaultChecked onChange={onChangeAll} />
                            </div>
                            <div className={style.radio}>
                                <div>내가 팔로우하는 사람들</div>
                                <input type="radio" name="pf" value="on" onChange={onChangeFollow} />
                            </div>
                        </div>
                    </div>
                </div>
            )
    }

    return (
        <div style={{marginBottom: 60, width: 'inherit'}}>
            <SearchForm/>
        </div>
    )
}

탐색하기 화면에서는 아무것도 보여지지 않고, 검색 결과에서는 검색 필터를 보여주고, 나머지 화면에서는 입력 폼을 보여준다.

일반 화면
검색 결과

사용자 필터를 변경하면 API를 호출하는 것이 아닌, router.replace 시킨다. Center 영역의 search 화면에서 searchParams 으로 검색어를 받아 API를 호출하는 방식이다.

TrendSection.tsx

유저들이 등록한 게시물 내용 중 작성한 해시태그를 내림차순으로 보여준다.

// /(afterLogin)/_component/TrendSection.tsx

"use client"
import style from './trendSection.module.css'
import Trend from "@/app/(afterLogin)/_component/Trend";
import {usePathname} from "next/navigation";
import {useSession} from "next-auth/react";
import {useQuery} from "@tanstack/react-query";
import {getTrends} from "@/app/(afterLogin)/_lib/getTrends";
import {Hashtag} from "@/model/Hashtag";
export default function TrendSection(){
    const {data: session} = useSession()

    const {data} = useQuery<Hashtag[]>({
        queryKey:['trends'],
        queryFn: getTrends,
        staleTime: 60*1000, //fresh -> stale (1분)
        gcTime: 300 * 1000,
        enabled:!!session?.user // 로그인 했을 경우에만 호출

    })
    const pathName = usePathname()

    // 검색 메뉴에서는 미노출
    if (pathName === '/explore') return null;
    if( session?.user){
        return(
            <div className={style.trendBg}>
                <div className={style.trend}>
                    <h3>나를 위한 트렌드</h3>
                    {
                        data?.map((trend)=>
                            <Trend trend={trend} key={trend.title} />
                        )
                    }
                </div>
            </div>
        )
    }else{
        return(
            <div className={style.trendBg}>
                <div className={style.noTrend}>
                    트렌드를 가져올 수 없습니다.
                </div>
            </div>
        )
    }

}

FollowRecommendSection.tsx

현재 팔로우하지 않은 다른 사용자들을 보여주고, 팔로우 할 경우 팔로잉 으로 버튼이 바뀐다.
팔로우와, 언팔로우 mutate 에는 Optimistic Update 가 적용되어 있다.

"use client"

import style from './followRecommend.module.css';
import {User} from "@/model/User";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {useSession} from "next-auth/react";
import cx from "classnames";
import Link from "next/link";
import {MouseEventHandler} from "react";
type Props = {
    user: User,
}
export default function FollowRecommend({user}: Props) {
    const queryClient = useQueryClient();
    const { data: session } = useSession();
    const followed = !!user.Followers.find((v)=> v.id === session?.user?.email)
    const follow = useMutation({
        mutationFn: (userId:string) =>{
            return fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/users/${userId}/follow`,{
                method:'post',
                credentials: 'include'
            })
        },
        onMutate(userId : string){
            //Optimistic Update
            const value: User[] | undefined = queryClient.getQueryData(["users", "followRecommends"]);
            if (value) {
                const index = value.findIndex((v) => v.id === userId);
                console.log(value, userId, index);
                const shallow = [...value];
                shallow[index] = {
                    ...shallow[index],
                    Followers: [{ id: session?.user?.email as string }],
                    _count: {
                        ...shallow[index]._count,
                        Followers: shallow[index]._count?.Followers + 1,
                    }
                }
                queryClient.setQueryData(["users", "followRecommends"], shallow)

                const value2: User | undefined = queryClient.getQueryData(["users", userId]);
                if (value2) {
                    const shallow = {
                        ...value2,
                        Followers: [{ id: session?.user?.email as string }],
                        _count: {
                            ...value2._count,
                            Followers: value2._count?.Followers + 1,
                        }
                    }
                    queryClient.setQueryData(["users", userId], shallow)
                }
            }
        },
        onError(error, userId: string) {
          // Optimistic Update Rollback
            const value: User[] | undefined = queryClient.getQueryData(["users", "followRecommends"]);
            if (value) {
                const index = value.findIndex((v) => v.id === userId);
                console.log(value, userId, index);
                const shallow = [...value];
                shallow[index] = {
                    ...shallow[index],
                    Followers: shallow[index].Followers.filter((v) => v.id !== session?.user?.email),
                    _count: {
                        ...shallow[index]._count,
                        Followers: shallow[index]._count?.Followers - 1,
                    }
                }
                queryClient.setQueryData(["users", "followRecommends"], shallow);
                const value2: User | undefined = queryClient.getQueryData(["users", userId]);
                if (value2) {
                    const shallow = {
                        ...value2,
                        Followers: value2.Followers.filter((v) => v.id !== session?.user?.email),
                        _count: {
                            ...value2._count,
                            Followers: value2._count?.Followers - 1,
                        }
                    }
                    queryClient.setQueryData(["users", userId], shallow)
                }
            }
        },
    })
    const unfollow = useMutation({
        mutationFn: (userId:String) =>{
            return fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/users/${userId}/follow`,{
                method:'delete',
                credentials: 'include'
            })
        },
        onMutate(userId: string){
            //Optimistic Update
            const value:User [] | undefined = queryClient.getQueryData(["users","followRecommends"])

            if(value){
                const index = value.findIndex((v)=>v.id === userId)
                const shallow = [...value]

                shallow[index] = {
                    ...shallow[index],
                    Followers: shallow[index].Followers.filter((v) => v.id !== session?.user?.email),
                    _count: {
                        ...shallow[index]._count,
                        Followers: shallow[index]._count?.Followers - 1,
                    }
                }
                queryClient.setQueryData(["users","followRecommends"], shallow)

                const value2: User | undefined = queryClient.getQueryData(["users", userId]);
                if (value2) {
                    const shallow = {
                        ...value2,
                        Followers: value2.Followers.filter((v) => v.id !== session?.user?.email),
                        _count: {
                            ...value2._count,
                            Followers: value2._count?.Followers - 1,
                        }
                    }
                    queryClient.setQueryData(["users", userId], shallow)
                }
            }
        },
        onError(error, userId: string) {
	      // Optimistic Update Rollback
            const value: User[] | undefined = queryClient.getQueryData(["users", "followRecommends"]);
            if (value) {
                const index = value.findIndex((v) => v.id === userId);
                console.log(value, userId, index);
                const shallow = [...value];
                shallow[index] = {
                    ...shallow[index],
                    Followers: [{ id: session?.user?.email as string }],
                    _count: {
                        ...shallow[index]._count,
                        Followers: shallow[index]._count?.Followers + 1,
                    }
                }
                queryClient.setQueryData(["users", "followRecommends"], shallow)
            }
            const value2: User | undefined = queryClient.getQueryData(["users", userId]);
            if (value2) {
                const shallow = {
                    ...value2,
                    Followers: [{ id: session?.user?.email as string }],
                    _count: {
                        ...value2._count,
                        Followers: value2._count?.Followers + 1,
                    }
                }
                queryClient.setQueryData(["users", userId], shallow)
            }
        },
    })
    const onFollow: MouseEventHandler<HTMLButtonElement> = (e) => {
        e.stopPropagation()
        e.preventDefault()

        if(followed){
            console.log('언팔')
            unfollow.mutate(user.id)
        }else{
            console.log('팔로우')
            follow.mutate(user.id)
        }
    };

    return (
        <Link href={`/${user.id}`} className={style.container}>
            <div className={style.userLogoSection}>
                <div className={style.userLogo}>
                    <img src={user.image} alt={user.id} />
                </div>
            </div>
            <div className={style.userInfo}>
                <div className={style.title}>{user.nickname}</div>
                <div className={style.count}>@{user.id}</div>
            </div>
            <div className={cx(style.followButtonSection, followed && style.followed)}>
                <button onClick={onFollow}>{followed ? '팔로잉' : '팔로우'}</button>
            </div>
        </Link>
    )
}

아직 모듈화를 하지 않아 소스가 길지만 구조는 매우 간단하다.
팔로우 여부에 따라 follow.mutationunfollow.mutation 이 실행되며

follow.mutation 에서 onMutate 시점에 Optimistic Update를 해주고 에러가 발생하면 unfollow.mutation 과 동일한 작업을 해주면 된다.
이와 정반대로
unfollow.mutation 에서 onMutate 시점에 Optimistic Update를 해주고 에러가 발생하면 follow.mutation 과 동일한 작업을 해주면 된다.

0개의 댓글