[인프런 워밍업 스터디 클럽 3기 풀스택] 3주차 발자국
강의수강
recoil
atom: 상태의 조각
useRecoilState: useState hook과 유사하게 전역적으로 recoil 상태를 읽고 쓰는 데 사용
useRecoilValue: recoil 상태를 읽는 데만 사용
react-intersection-observer
intersercion Observer API를 react에서 사용하기 쉽도록 도와주는 라이브러리
useInView
훅은 react-intersection-observer 라이브러리에서 제공하는 기능으로, 특정 요소가 화면에 보이는지(inView
)를 감지하고, 해당 요소에ref
를 할당하여 추적할 수 있게 한다.threshold: 0
옵션은 요소의 0%라도 화면에 나타나면inView
가true
가 되도록 설정
useInfiniteQuery
const { data, isFetchingNextPage, isFetching, fetchNextPage, hasNextPage } =
useInfiniteQuery({
initialPageParam: 1,
queryKey: ["movie", search],
queryFn: ({ pageParam }) =>
searchMovies({
search,
pageSize,
page: pageParam,
favoriteCount,
}),
getNextPageParam: (lastPage) => {
if (lastPage.data.length < pageSize) {
return null;
}
return lastPage.page ? lastPage.page + 1 : null;
},
});
무한 스크롤 기능을 쉽게 구현할 수 있도록 해주는 hook
initialPageParam 으로 처음에 가져올 페이지 번호를 설정
queryFn: -> searchMovies 함수를 호출해 데이터를 패치
getNextPageParam: 다음 페이지 번호를 반환해 추가 데이터를 불러 오도록 함 조건에 따라 null을 반환해 무한 스크롤을 멈출 수 있게 해준다
미션
사용자가 특정 영화를 “찜”할 수 있도록 Supabase를 활용해 즐겨찾기 리스트 구현
supabase movie 테이블에 favorite: boolean column 추가 기본 값: false
actions/move-actions.ts에 update action 코드 추가
type MovieUpdate = Database["public"]["Tables"]["movie"]["Update"];
export async function updateMovie(movie: MovieUpdate) {
const supabase = await createServerSupabaseClient();
const { error } = await supabase
.from("movie")
.update({
...movie,
})
.eq("id", movie.id);
handleError(error);
}
현재 Movie가 찜되어 있는지 나타내는 컴포넌트 MovieFavoriteIcon
favorite boolean값을 받아 찜했으면 핑크색 하트를 찜 안되어있으면 빈하트를 렌더링
export default function MovieFavoriteIcon({ favorite }: { favorite: boolean }) {
return (
<>
{favorite ? (
<i
className="fa-solid fa-heart font-bold"
style={{ fontSize: 25, color: "#ff9fd2" }}
/>
) : (
<i
className="fa-regular fa-heart"
style={{ fontSize: 25, color: "#fff" }}
/>
)}
</>
);
}
components/movie-card, movies/[id]/ui에 MovieFavoriteIcon 컴포넌트 추가
import Link from "next/link";
import type { Movie } from "actions/movie-actions";
import MovieFavoriteIcon from "./movie-favorite-icon";
export default function MovieCard({ movie }: { movie: Movie }) {
return (
<div className="col-span-1 relative">
<img src={movie.image_url} className="w-full" />
<Link href={`/movies/${movie.id}`}>
<div className="absolute top-0 right-0 px-1.5 py-2">
<MovieFavoriteIcon favorite={movie.favorite} />
</div>
<div className="absolute top-0 bottom-0 left-0 right-0 z-10 flex justify-center items-center bg-black opacity-0 hover:opacity-80 transition-opacity duration-300">
<p className="text-xl font-bold text-white">{movie.title}</p>
</div>
</Link>
</div>
);
}
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Movie, updateMovie } from "actions/movie-actions";
import { queryClient } from "config/react-query-client-provider";
import MovieFavoriteIcon from "components/movie-favorite-icon";
export default function UI({ movie }: { movie: Movie }) {
const [isFavorite, setIsFavorite] = useState(movie.favorite);
const updateMovieMutation = useMutation({
mutationFn: () =>
updateMovie({
...movie,
favorite: !isFavorite,
}),
onSuccess: () => {
setIsFavorite((prev) => !prev);
queryClient.invalidateQueries({ queryKey: ["movie"] });
},
});
return (
<div className="flex flex-col md:flex-row items-center">
<img src={movie.image_url} className="w-1/3 xl:w-1/4" />
<div className="w-full md:w-2/3 items-center md:items-start flex flex-col p-6 gap-4">
<div className="flex justify-between items-center w-full">
<h1 className="text-3xl font-bold">{movie.title}</h1>
<div>
<button
className="bg-pink-50 border-2 border-pink-100 rounded-md px-1.5 py-1"
onClick={() => updateMovieMutation.mutate()}
>
<MovieFavoriteIcon favorite={isFavorite} />
</button>
</div>
</div>
<p className="text-lg font-medium">{movie.overview}</p>
<div className="font-bold text-lg">
<i className="fas fa-star mr-1" />
Vote Average : {movie.vote_average}
</div>
<div className="font-bold text-lg">Popularity: {movie.popularity}</div>
<div className="font-bold text-lg">
Release Date: {movie.release_date}
</div>
</div>
</div>
);
}
movies/[id]/ui.tsx에서 해당 movie의 favorite을 설정하도록 mutate 추가
찜한 영화를 영화 리스트 화면의 최상단으로 보여주도록 정렬
위에서 추가했던 movie 테이블의 favorite
컬럼을 기준으로, 무한 스크롤 movie 요청시 찜한 영화를 우선해 가지고 오는 것을 목표로 했다.
찜한 영화 favoriteMovie
, 나머지 기본 영화 remainMovie
를 각각 가지고 오는 방식으로,
예를 들어
favoriteMovie
가 6개이고pageSize
가 12인 경우
page = 1:
favoriteMovie.length = 6
,remainMovie.length = 6
page = 2:
favoriteMovie.length = 0
,remainMovie.length = 12
page = 3:
favoriteMovie.length = 0
,remainMovie.length = 12
favoriteMovie
가 13개이고pageSize
가 12인 경우
page = 1:
favoriteMovie.length = 12
,remainMovie.length = 0
page = 2:
favoriteMovie.length = 1
,remainMovie.length = 11
page = 3:
favoriteMovie.length = 0
,remainMovie.length = 12
favoriteMoive
를 먼저 pageSize
만큼 가지고 오고 favoriteMoive
의 수가 pageSize
보다 작은 경우 remainMovie
를 가지고 오는 방식이다.
여기서 가장 중요한 것은 각각의 영화들의 range
다. 기존 page
와 pageSize
를 기준으로 range
를 정하는 경우 remainMovie
를 쿼리할 때 중간에 지나가는 경우가 생긴다. 위 2번 째 예의 page = 2일 때 remainMovie
의 범위는 0 ~ 10 이여야 한다. 이전 Movie들의 정보를 알아야 구할 수 있는데, 현재 코드에서는 이를 간단하게 구할 좋은 방법이 떠오르지 않았다. 따라서 supabase에 저장한 favoriteMovie
의 수를 미리 가지고와 이를 이용해 favoriteCount
, page
, pageNumber
세 개의 값들을 조합해 범위를 구했다.
function getRange(favoriteCount: number, page: number, pageSize: number) {
const start = (page - 1) * pageSize; // 현재 페이지의 시작 인덱스
const end = start + pageSize - 1; // 현재 페이지의 마지막 인덱스
const favStart = start < favoriteCount ? start : favoriteCount;
const favEnd = Math.min(end, favoriteCount - 1);
const favLength = Math.max(favEnd - favStart + 1, 0);
const remStart = Math.max(0, start - favoriteCount);
const remEnd = remStart + (pageSize - favLength) - 1;
const remLength = Math.max(remEnd - remStart + 1, 0);
return {
favoriteRange: favLength > 0 ? [favStart, favEnd] : [],
remainRange: remLength > 0 ? [remStart, remEnd] : [],
};
}
getRange
함수는 favoriteCount
, page
, pageNumber
세 개의 값들을 이용해 favoriteMovie
, remainMovie
의 범위를 구해 반환하는 함수다.
현재 페이지의 시작 인덱스 start
가 찜한 영화의 수 favoriteCount
보다 작은 경우 이번 페이지에서는 찜한 영화만을 가지고 올 거기에 favStart
가 start
가 된다. 크거나 같은 경우에는 찜한 영화가 부족함으로 favoriteCount
값으로 설정한다.
favEnd
= end
가 favoriteCount
보다 큰 경우, 존재하는 찜한 영화 개수까지만 가져온다.
remStart
= 찜한 영화 개수를 넘어서야 나머지 영화를 가지고 오므로 start - favoriteCount
가 0보다 큰 경우인지 확인하면 값을 설정
remEnd
= 찜한 영화 개수(favLength
) 를 채운 후 남은 공간만큼 나머지 영화를 가지고 온다
최종 변경 코드
actions/movie-actions.ts
async function searchMoviesByFavorite(
search: string,
range: number[],
isFavorite: boolean
) {
if (range.length === 0) {
return [];
}
const [start, end] = range;
const supabase = await createServerSupabaseClient();
const { data, error } = await supabase
.from("movie")
.select("*")
.like("title", `%${search}%`)
.eq("favorite", isFavorite)
.order("id")
.range(start, end);
handleError(error);
return data;
}
export async function searchMovies({
search,
page,
pageSize,
favoriteCount,
}: {
search: string;
page: number;
pageSize: number;
favoriteCount: number;
}) {
const { favoriteRange, remainRange } = getRange(
favoriteCount,
page,
pageSize
);
const [favoriteMovies, remainMovies] = await Promise.all([
searchMoviesByFavorite(search, favoriteRange, true),
searchMoviesByFavorite(search, remainRange, false),
]);
const data = [...favoriteMovies, ...remainMovies];
return {
data,
page,
pageSize,
hasNextPage: data.length === pageSize,
};
}
app/page.tsx
// page.tsx
import { createServerSupabaseClient } from "utils/supabase/server";
import UI from "./ui";
export const metadata = {
title: "TMDBFLIX",
description: "Netflix clone using TMDB API",
};
export default async function Page() {
const supabase = await createServerSupabaseClient();
const { count } = await supabase
.from("movie")
.select("*", { count: "exact", head: true })
.eq("favorite", true);
return <UI favoriteCount={count} />;
}
// ui.tsx
"use client";
import MovieCardList from "components/movie-card-list";
export default function UI({ favoriteCount }: { favoriteCount: number }) {
return (
<main className="mt-14 mb-12">
<MovieCardList favoriteCount={favoriteCount} />
</main>
);
}
MovieCardList
컴포넌트에서 사용할 수 있도록 총 찜한 영화의 수를 쿼리해서 prop으로 전달
components/Movie-card-list.tsx
export default function MovieCardList({
favoriteCount,
}: {
favoriteCount: number;
}) {
const search = useRecoilValue(searchState);
const pageSize = 12;
const { data, isFetchingNextPage, isFetching, fetchNextPage, hasNextPage } =
useInfiniteQuery({
initialPageParam: 1,
queryKey: ["movie", search],
queryFn: ({ pageParam }) =>
searchMovies({
search,
pageSize,
page: pageParam,
favoriteCount,
}),
getNextPageParam: (lastPage) => {
if (lastPage.data.length < pageSize) {
return null;
}
return lastPage.page ? lastPage.page + 1 : null;
},
});
...
}
찜한 영화의 수 favoriteCount
를 useInfiniteQuery
queryFn
searchMovies
에 추가로 전달
댓글을 작성해보세요.