
자 이제 header에 있는 tv Show 부분을 누르면 tv 링크로 이동하고,
tv와 관련된 정보를 쫙 뿌려주면 된다
사실 거의 코드 패턴튼 Home (movie)를 불러왔던 부분과 똑같기 때문에 쉽게 진행할 수 있었다.
Tv도 마찬가지로 겹치는 tv show 는 보여주지 않기 위해서
getDistinctTvs() 로popular, ontheAir, topRanked를 각각의 tvId를 가져온 뒤, 겹치지 않게 filter 처리를 해준 뒤 return 해주었다!
export interface ITv {
id: number;
backdrop_path: string;
poster_path: string;
name: string;
overview: string;
vote_average: string;
release_date: string;
genre_ids: [];
}
export interface IGetTvResult {
dates: {
maximum: string;
minimum: string;
};
page: number;
total_pages: number;
total_results: number;
results: ITv[];
}
export interface IGetTvDetail {
episode_run_time: number;
first_air_date: string;
last_air_date: string;
genres: IDetailTv[];
number_of_episodes: number;
number_of_seasons: number;
seasons: ISeason[];
status: string;
tagline: string;
type: string;
vote_average: number;
id: number;
backdrop_path: string;
name: string;
overview: string;
}
export interface IDetailTv {
id: number;
name: string;
}
export interface ISeason {
air_date: string;
episode_count: number;
id: number;
name: string;
overview: string;
poster_path: string;
season_number: number;
vote_average: number;
}
export async function getDistinctTvs(): Promise<{
popular: ITv[];
ontheAir: ITv[];
topRanked: ITv[];
}> {
const popularResult = await getPopularTvs();
const onTheAirResult = await getOnTheAirTvs();
const topRankedResult = await getTopRatedTvs();
const popularIds = new Set(popularResult.results.map((tv) => tv.id));
const ontheAirTvs = onTheAirResult.results.filter(
(tv) => !popularIds.has(tv.id)
);
const topRankedTvs = topRankedResult.results.filter(
(tv) =>
!popularIds.has(tv.id) &&
!ontheAirTvs.some((ontheAirTv) => ontheAirTv.id === tv.id)
);
return {
popular: popularResult.results,
ontheAir: ontheAirTvs,
topRanked: topRankedTvs,
};
}
export function getTvDetail(tvId: string) {
return fetch(
`${BASE_PATH}/tv/${tvId}?api_key=${API_KEY}&language=ko-KR`
).then((response) => response.json());
}

const TV = () => {
const { data, isLoading } = useQuery(['tvs', 'distinct'], getDistinctTvs);
return (
<>
<Wrapper>
{isLoading ? (
<Loader>
<Loading />
</Loader>
) : (
<>
<Banner
bgPhoto={makeImagePath(data?.popular[0].backdrop_path || '')}
>
<Title>{data?.popular[0].name}</Title>
<Overview>{data?.popular[0].overview}</Overview>
</Banner>
<TvComponent />
</>
)}
</Wrapper>
</>
);
};
export default TV;
딱 배너에 나타날 부분을 작성해주었다.
tvShow의 api중, popular에서 첫번째 data (data?.popular[0]) 를 가져와서 뿌려주었다

먼저 이 부분을 구현하기 위해
슬라이더 부분, 영화를 하나 눌렀을 때 자세한 정보를 보여줄 Box 부분 이렇게 두 컴포넌트로 나눠서 코드를 작성해보았다.
tv부분에서는 --> Popular, On The Air, Top Ranked 3가지이 데이터를 받아오는데, 이는 위에서 설명했듯이 getDistinctTvs()로 서로 겹치는 데이터를 제거해 놓았기 때문에
getDistinctTvs()를 통해서 세개를 모두 받아오면 된다.
const { data, isLoading } = useQuery('distinctTvs', getDistinctTvs);
const { data: tvDetail } = useQuery<IGetTvDetail>(['detail', tvId], () =>
getTvDetail(tvId || '')
);
도 같이 데이터를 불러온다
getTvDetail() 를 사용하기 위해서는 해당 tvId를 전달해주면, 해당 tv Show에 대한 자세한 정보를 불러올 수 있다.
{data && (
<>
<SliderTv
title="Popular"
tvs={data.popular}
category="popular"
/>
<SliderTv
title="On The Air"
tvs={data.ontheAir}
category="ontheAir"
/>
<SliderTv
title="Top Ranked"
tvs={data.topRanked}
category="topRanked"
/>
</>
)}
getDistinctTvs()에서 불러오는 data에서 각각 해당하는 정보를 나눠서 SliderTv 컴포넌트에게 보내주면,

요렇게 interface에 타입을 지정해주고 받아서 사용할 수 있다
(return 부분은 home(movie)부분과 비슷하기 때문에 설명하지 않고 넘어가겠다)

BigTvShow는 이렇게 구성을 해봤다.
movie와 다른 점은 season이 추가가 됬다는 점이다

이렇게 시즌을 선택할 수 있고,

해당 시즌에 대한 간단한 정보를 이렇게 띄워주는 것까지 완료했다.
(season을 제외한 나머지 부분은 home(Movie) 부분과 비슷하기 때문에 생략하겠다)
interface ISeasonProps {
seasons: ISeason[];
onSeasonSelect: (seasonId: number) => void;
}
const SeasonSelector = ({ seasons, onSeasonSelect }: ISeasonProps) => {
return (
<Select onChange={(e) => onSeasonSelect(Number(e.target.value))}>
<Option value="">Season</Option>
{seasons.map((season) => (
<Option key={season.id} value={season.id}>
{season.name}
</Option>
))}
</Select>
);
};
export default SeasonSelector;
TV 시리즈의 시즌을 선택할 수 있는 셀렉트 박스를 표시해 주었다.
시즌을 선택하면 해당 시즌에 대한 모달을 보여주도록 구성했다.
const TvComponent = () => {
const navigate = useNavigate();
const { scrollY } = useScroll();
const bigTvMatch = useMatch('/tv/:category/:tvId');
const tvId = bigTvMatch?.params.tvId;
const category = bigTvMatch?.params.category;
const { data, isLoading } = useQuery('distinctTvs', getDistinctTvs);
const [videoData, setVideoData] = useState<IGetVideosResult>();
const [selectedSeason, setSelectedSeason] = useState<ISeason | null>(null);
const [showSeasonModal, setShowSeasonModal] = useState(false); // State for modal visibility
const { data: tvDetail } = useQuery<IGetTvDetail>(['detail', tvId], () =>
getTvDetail(tvId || '')
);
const onOverlayClicked = () => {
navigate('/tv');
};
useEffect(() => {
const fetchVideoData = async () => {
if (tvId) {
const videoData = await getTvVideos(tvId);
setVideoData(videoData);
}
};
fetchVideoData();
}, [tvId]);
const clickTv =
tvId &&
data &&
category &&
data[category as keyof typeof data].find((tv) => tv.id + '' === tvId);
const handleSeasonSelect = (seasonId: number) => {
const selected =
tvDetail?.seasons.find((season) => season.id === seasonId) || null;
setSelectedSeason(selected);
setShowSeasonModal(true); // Show the modal on season select
};
const closeSeasonModal = () => {
setShowSeasonModal(false);
};
return (
<Container>
{data && (
<>
<SliderTv title="Popular" tvs={data.popular} category="popular" />
<SliderTv
title="On The Air"
tvs={data.ontheAir}
category="ontheAir"
/>
<SliderTv
title="Top Ranked"
tvs={data.topRanked}
category="topRanked"
/>
</>
)}
<AnimatePresence>
{bigTvMatch && clickTv ? (
<>
<Overlay
onClick={onOverlayClicked}
exit={{ opacity: 0 }}
animate={{ opacity: 1 }}
/>
<BigMovie style={{ top: scrollY.get() + 100 }} layoutId={tvId}>
{clickTv && videoData && videoData.results.length > 0 ? (
<Video videos={videoData.results} />
) : (
<BigCover
style={{
backgroundImage: `linear-gradient(to top , black, transparent), url(${makeImagePath(
clickTv.backdrop_path,
'w500'
)})`,
}}
/>
)}
<BigTitle>{clickTv.name}</BigTitle>
<Explanation>
<CategoryLabel>
{category === 'popular' && popularFont}
{category === 'ontheAir' && ontheAirFont}
{category === 'topRanked' && topRankedTvFont}
</CategoryLabel>
<BigRelease>
{tvDetail?.first_air_date.slice(0, 4) +
' ' +
tvDetail?.first_air_date.slice(5, 7)}
</BigRelease>
{tvDetail &&
tvDetail.episode_run_time &&
tvDetail.episode_run_time + '' !== '' ? (
<RunTime>{tvDetail.episode_run_time}분</RunTime>
) : null}
<BigVoteAverage>
⭐️ {Number(clickTv.vote_average).toFixed(2)}
</BigVoteAverage>
{tvDetail && tvDetail.seasons.length > 0 && (
<>
<SeasonSelector
seasons={tvDetail.seasons}
onSeasonSelect={handleSeasonSelect}
/>
</>
)}
</Explanation>
<ExplanationSub>
<BigOverview>{clickTv.overview}</BigOverview>
{tvDetail && (
<Detail>
{tvDetail.genres.map((genre) => (
<GenreTitle key={genre.id}>{genre.name}</GenreTitle>
))}
<Type>Type : {tvDetail?.type}</Type>
</Detail>
)}
</ExplanationSub>
</BigMovie>
</>
) : null}
</AnimatePresence>
{selectedSeason && showSeasonModal && (
<SeasonModal
season={selectedSeason}
seasons={tvDetail!.seasons}
onClose={closeSeasonModal}
onSeasonSelect={handleSeasonSelect}
/>
)}
</Container>
);
};
export default TvComponent;
const SeasonModal = ({
season,
seasons,
onClose,
onSeasonSelect,
}: SeasonModalProps) => {
const [selectedSeason, setSelectedSeason] = useState<ISeason>(season);
const [videoData, setVideoData] = useState<IGetVideosResult>();
const tvId = season.id;
useEffect(() => {
setSelectedSeason(season);
}, [season]);
const handleSeasonChange = (seasonId: number) => {
const selected = seasons.find((s) => s.id === seasonId);
if (selected) {
setSelectedSeason(selected);
onSeasonSelect(seasonId);
}
};
const overview =
selectedSeason.overview.length > 200
? selectedSeason.overview.slice(0, 200) + '...'
: selectedSeason.overview;
useEffect(() => {
const fetchVideoData = async () => {
if (tvId) {
const videoData = await getTvVideos(tvId + '');
setVideoData(videoData);
}
};
fetchVideoData();
}, [tvId]);
return (
<ModalOverlay
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
<SeasonBox onClick={(e) => e.stopPropagation()}>
{videoData && videoData.results && videoData.results.length > 0 ? (
<Video videos={videoData.results} />
) : (
<CoverImage
backgroundImage={`linear-gradient(to top, black, transparent), url(${makeImagePath(
selectedSeason.poster_path,
'w500'
)})`}
/>
)}
<Explanation>
<SeasonSelector
seasons={seasons}
onSeasonSelect={handleSeasonChange}
/>
<SeasonTitle>{selectedSeason.name}</SeasonTitle>
<ExplanationText>
에피소드 수: {selectedSeason.episode_count}
</ExplanationText>
<ExplanationText>
방영일: 🗓️ {selectedSeason.air_date}
</ExplanationText>
<ExplanationText>
평균 평점: ⭐️ {selectedSeason.vote_average}
</ExplanationText>
{selectedSeason.overview && (
<SeasonOverview>{overview}</SeasonOverview>
)}
</Explanation>
</SeasonBox>
</ModalOverlay>
);
};
export default SeasonModal;
간단하게 두 코드에 대해서 설명하자면
켈켈.. season 요녀석 생각보다 시간이 많이 걸렸다
불러오는 Api와 저 Season을 제외하고는 거의 Home(Movie)의 코드와 거의 비슷해서 시간은 오래 걸리진 않은 것 같다!
그래도 똑같은 형태로 코드를 작성해도 왜 다르게 나오고 오류가 뜨는지 나는 알 수 가 없었지만 ~~~ 생각보다 예쁘게 잘 마무리 지은 것 같다!