diff --git a/.gitignore b/.gitignore index 50c8dda2af..614cbe4ffe 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ dist-ssr *.sw? .env +cypress/screenshots \ No newline at end of file diff --git a/README.md b/README.md index 1dd510533d..c1da7ad067 100644 --- a/README.md +++ b/README.md @@ -50,3 +50,53 @@ FE 레벨1 영화 리뷰 미션 - [x] 스켈레톤 UI가 로딩중에 보이는지 테스트 - [x] 영화 검색시 필터링된 영화가 보이는지 테스트 - [x] 영화 검색시 검색어가 섹션 헤딩에 보이는지 테스트 + +# step2 상세 정보 & UI/UX 개선하기 + +## 기능 요구사항 분석 + +- [x] 영화 상세정보 조회 + - [x] 모달 컴포넌트를 만든다. + - [x] 영화 클릭시 모달이 렌더링된다. + - [x] 모달이 렌더링되었을때 외부영역이 스크롤되지 않도록 한다. + - [x] 영화 장르를 가져온다. + - [x] 모달을 닫는다. + - [x] 모달에는 영화의 상세정보를 담는다.(줄거리, 내 별점, 카테고리) + - [x] 사용자가 기록한 별점은 로컬 스토리지에 저장한다. (로컬 스토리지로부터 저장하고 가져온다..) + +- [x] 영화 더보기 페이징을 무한 스크롤로 전환 + - [x] intersection observer를 통해 구현 + - [x] 특정 지점의 스크롤 기준(thumbnail list 블럭 끝), loadMore 트리거 + - [x] 기존 더보기 관련 렌더링, 이벤트 핸들러 등록 제거 + +- [x] 반응형 웹 만들기 + - [x] Tablet + - [x] 헤더 + - [x] 모달 + - [x] 위치 + - [x] 상세정보 + + - [x] Mobile + - [x] 헤더 + - [x] 모달 + - [x] 위치 + - [x] 상세정보 + +## 개선사항 + +- [x] setUpMovieDetail에서 더보기 시 eventListener를 전부 등록하지 않고 추가된 데이터에만 적용할 수 없을까? +- [x] 영화의 자세한 정보가 담긴 모달을 정적인 형태로 두기. + - [x] dialog 태그로 전환 + - [x] 동적으로 변경되어야할 부분만 따로 변경 + +## 리팩토링 + +- [x] main로직을 분리 + - [x] index + - [x] search + - [x] movieDetail + +- [x] renderMovieDetail, renderMyRating을 Renderer로 이동 +- [x] renderMyRating에 switch/case 부분을 분리 + +- [x] 상수분리 diff --git a/cypress/e2e/spec.cy.ts b/cypress/e2e/spec.cy.ts index 133e0d3796..ba8c7357d7 100644 --- a/cypress/e2e/spec.cy.ts +++ b/cypress/e2e/spec.cy.ts @@ -1,10 +1,10 @@ -const createMockMovie = (id: number) => ({ +export const createMockMovie = (id: number) => ({ id, title: `어벤져스 ${id}`, poster_path: `/avengers${id}.jpg`, vote_average: 7.5, backdrop_path: `/backdrop${id}.jpg`, - genre_ids: [28], + genre_ids: [12, 99], original_language: "ko", original_title: `어벤져스 ${id}`, overview: "타노스를 조심해", @@ -15,12 +15,16 @@ const createMockMovie = (id: number) => ({ adult: false, }); -const createMoviesResponse = (count: number, page: number = 1) => ({ +export const createMoviesResponse = ( + count: number, + page: number = 1, + totalPages: number = 500, +) => ({ page, results: Array.from({ length: count }, (_, i) => createMockMovie((page - 1) * 20 + i + 1), ), - total_pages: 500, + total_pages: totalPages, total_results: 10000, }); @@ -29,6 +33,9 @@ describe("영화 리뷰 앱", () => { cy.intercept("GET", "**/movie/popular*", createMoviesResponse(20)).as( "getPopularMovies", ); + cy.intercept("GET", "**/genre/movie/list*", { + fixture: "genres.json", + }).as("getGenres"); cy.visit("/"); }); @@ -63,45 +70,48 @@ describe("영화 리뷰 앱", () => { }); describe("더 보기", () => { - it("더 보기 클릭 시 영화가 20개 추가 렌더링된다", () => { + it("end-of-thumbnail-list에 도달했을떄 영화가 20개 추가 렌더링된다", () => { cy.wait("@getPopularMovies"); cy.intercept("GET", "**/movie/popular*", createMoviesResponse(20, 2)).as( "getMoreMovies", ); - cy.get(".load-more-button").click(); + cy.get("#end-of-thumbnail-list").scrollIntoView(); cy.wait("@getMoreMovies"); cy.get(".thumbnail-list li").should("have.length", 40); }); - it("마지막 페이지일 때 더 보기 버튼이 숨겨진다", () => { + it("마지막 페이지일 때 더 이상 영화를 가져오지 않는다", () => { cy.wait("@getPopularMovies"); + cy.intercept( + "GET", + "**/movie/popular*", + createMoviesResponse(5, 1, 1), + ).as("getMoreMovies"); + cy.get("#end-of-thumbnail-list").scrollIntoView(); + cy.wait("@getMoreMovies"); - cy.intercept("GET", "**/movie/popular*", createMoviesResponse(5, 2)).as( - "getLastPageMovies", - ); - - cy.get(".load-more-button").click(); - cy.wait("@getLastPageMovies"); + cy.get(".thumbnail-list li").should("have.length", 25); - cy.get(".load-more-button").should("not.be.visible"); + cy.get("#end-of-thumbnail-list").scrollIntoView(); + cy.wait(500); + cy.get(".thumbnail-list li").should("have.length", 25); }); it("더 보기 API 실패 시 에러 메시지가 렌더링된다", () => { cy.wait("@getPopularMovies"); cy.intercept("GET", "**/movie/popular*", { statusCode: 500 }).as( - "getMoreMoviesError", + "getMoreMovies", ); - cy.get(".load-more-button").click(); - cy.wait("@getMoreMoviesError"); - + cy.get("#end-of-thumbnail-list").scrollIntoView(); + cy.wait("@getMoreMovies"); cy.get(".notice-text").should( "contain.text", - "영화 정보를 불러오는 데 실패했습니다.", + "오류가 발생했습니다. 다시 시도해주세요.", ); }); }); @@ -122,39 +132,28 @@ describe("영화 리뷰 앱", () => { cy.get("section > h2").should("contain.text", "액션"); }); - it("검색 결과가 마지막 페이지일 때 더 보기 버튼이 숨겨진다", () => { - cy.wait("@getPopularMovies"); - - cy.intercept("GET", "**/search/movie*", createMoviesResponse(5)).as( - "searchLastPage", - ); - - cy.get(".search-form input").type("액션"); - cy.get(".search-form").submit(); - cy.wait("@searchLastPage"); - - cy.get(".load-more-button").should("not.be.visible"); - }); - - it("검색 더 보기에서 마지막 페이지일때 더 보기 버튼이 숨겨진다", () => { + it("검색 결과가 마지막 페이지일 때 더 이상 영화를 가져오지 않는다.", () => { cy.wait("@getPopularMovies"); - cy.intercept("GET", "**/search/movie*", createMoviesResponse(20)).as( - "searchMovies", + cy.intercept("GET", "**/search/movie*", createMoviesResponse(3, 1, 1)).as( + "getMoreMovies", ); + cy.intercept( + { method: "GET", url: "**/search/movie*", times: 1 }, + createMoviesResponse(20), + ).as("searchMovies"); cy.get(".search-form input").type("액션"); cy.get(".search-form").submit(); cy.wait("@searchMovies"); - cy.intercept("GET", "**/search/movie*", createMoviesResponse(3, 2)).as( - "searchLastPage", - ); - - cy.get(".load-more-button").click(); - cy.wait("@searchLastPage"); + cy.get("#end-of-thumbnail-list").scrollIntoView(); + cy.wait("@getMoreMovies"); + cy.get(".thumbnail-list li").should("have.length", 23); - cy.get(".load-more-button").should("not.be.visible"); + cy.get("#end-of-thumbnail-list").scrollIntoView(); + cy.wait(500); + cy.get(".thumbnail-list li").should("have.length", 23); }); it("검색 결과가 없을 때 안내 메시지가 렌더링된다", () => { @@ -186,15 +185,14 @@ describe("영화 리뷰 앱", () => { cy.wait("@searchMovies"); cy.intercept("GET", "**/search/movie*", { statusCode: 500 }).as( - "searchMoreMoviesError", + "getMoreMovies", ); - - cy.get(".load-more-button").click(); - cy.wait("@searchMoreMoviesError"); + cy.get("#end-of-thumbnail-list").scrollIntoView(); + cy.wait("@getMoreMovies"); cy.get(".notice-text").should( "contain.text", - "영화 정보를 불러오는 데 실패했습니다.", + "오류가 발생했습니다. 다시 시도해주세요.", ); }); }); @@ -210,7 +208,7 @@ describe("영화 리뷰 앱", () => { cy.get(".notice-text").should( "contain.text", - "영화 정보를 불러오는 데 실패했습니다.", + "오류가 발생했습니다. 다시 시도해주세요.", ); }); @@ -234,5 +232,112 @@ describe("영화 리뷰 앱", () => { cy.get(".thumbnail-list li").should("have.length", 5); cy.get("section > h2").should("contain.text", "액션"); }); + + [ + ["인증에 실패했습니다. API 키를 확인해주세요.", 401], + ["요청한 정보를 찾을 수 없습니다", 404], + ["오류가 발생했습니다. 다시 시도해주세요.", 500], + ].forEach(([message, statusCode]) => { + it(`API ${statusCode} 에러 시 에러 메시지가 렌더링된다`, () => { + cy.wait("@getPopularMovies"); + + cy.intercept("GET", "**/movie/popular*", { statusCode }).as( + "getMoreMovies", + ); + + cy.get("#end-of-thumbnail-list").scrollIntoView(); + cy.wait("@getMoreMovies"); + + cy.get(".notice-text").should("contain.text", message); + }); + }); + }); + + describe("영화 정보", () => { + const origin = new URL(Cypress.config("baseUrl") as string).origin; + beforeEach(() => { + cy.wait("@getPopularMovies"); + cy.wait("@getGenres"); + }); + it("영화를 클릭하면 영화에 대한 자세한 정보가 담긴 모달이 렌더링된다", () => { + cy.get(".thumbnail-list li").first().click(); + cy.get(".modal").should("be.visible"); + }); + + it("영화 카테고리 아이디는 이름으로 변환되어 렌더링된다.", () => { + cy.get(".thumbnail-list li").first().click(); + cy.get("#movie-detail-category").should("have.text", "모험, 다큐멘터리"); + cy.get("#movie-detail-release-year").should("have.text", "2026"); + }); + + it("모달 닫기 버튼을 클릭하면 영화 정보 모달이 제거된다.", () => { + cy.get(".thumbnail-list li").first().click(); + cy.get("#closeModal").click(); + cy.get("dialog").should("not.be.visible"); + }); + + it("영화를 클릭하면 영화 ID가 영화에 대한 자세한 정보가 담긴 컨테이너의 data-movie-id 속성에 저장된다.", () => { + cy.get(".thumbnail-list li").eq(2).click(); + cy.get("#movie-detail-container") + .invoke("attr", "data-movie-id") + .should("eq", "3"); + }); + + it("별점을 클릭하면 로컬스토리지에 선택된 영화에 내 평점이 저장된다.", () => { + function assertLocalStorageValue(key: string, value: string) { + cy.getAllLocalStorage().then((result) => { + expect(result).to.deep.equal({ + [origin]: { + [key]: value, + }, + }); + }); + } + cy.get(".thumbnail-list li").eq(4).click(); + cy.get(".my-rating-container button").eq(0).click(); + assertLocalStorageValue("movie-5-my-rating", "2"); + cy.get(".my-rating-container button").eq(1).click(); + assertLocalStorageValue("movie-5-my-rating", "4"); + cy.get(".my-rating-container button").eq(2).click(); + assertLocalStorageValue("movie-5-my-rating", "6"); + cy.get(".my-rating-container button").eq(3).click(); + assertLocalStorageValue("movie-5-my-rating", "8"); + cy.get(".my-rating-container button").eq(4).click(); + assertLocalStorageValue("movie-5-my-rating", "10"); + }); + + it("별점을 클릭하면 별점에 따라 내 평점 렌더링이 변화된다.", () => { + const ratingCases = [ + { buttonIndex: 0, rating: 2, label: "최악이에요", filledCount: 1 }, + { buttonIndex: 1, rating: 4, label: "별로에요", filledCount: 2 }, + { buttonIndex: 2, rating: 6, label: "보통이에요", filledCount: 3 }, + { buttonIndex: 3, rating: 8, label: "재미있어요", filledCount: 4 }, + { buttonIndex: 4, rating: 10, label: "명작이에요", filledCount: 5 }, + ]; + + cy.get(".thumbnail-list li").eq(4).click(); + + ratingCases.forEach(({ buttonIndex, rating, label, filledCount }) => { + cy.get(".my-rating-container button").eq(buttonIndex).click(); + cy.get("#my-rating-to-string").should("have.text", label); + cy.get("#my-rating-ratio").should("have.text", `(${rating}/10)`); + for (let i = 0; i < ratingCases.length; i++) { + const expectedSrc = + i < filledCount ? "star_filled.png" : "star_empty.png"; + cy.get("#my-rating button") + .eq(i) + .find("img") + .should("have.attr", "src") + .and("include", expectedSrc); + } + }); + }); + + it("더 가져오기 이후에 존재했던 영화를 클릭시 자세한 정보가 담긴 모달이 렌더링된다.", () => { + cy.get("#end-of-thumbnail-list").scrollIntoView(); + cy.wait(500); + cy.get(".thumbnail-list li").first().click(); + cy.get(".modal").should("be.visible"); + }); }); }); diff --git a/cypress/fixtures/genres.json b/cypress/fixtures/genres.json new file mode 100644 index 0000000000..bce2de1265 --- /dev/null +++ b/cypress/fixtures/genres.json @@ -0,0 +1,80 @@ +{ + "genres": [ + { + "id": 28, + "name": "액션" + }, + { + "id": 12, + "name": "모험" + }, + { + "id": 16, + "name": "애니메이션" + }, + { + "id": 35, + "name": "코미디" + }, + { + "id": 80, + "name": "범죄" + }, + { + "id": 99, + "name": "다큐멘터리" + }, + { + "id": 18, + "name": "드라마" + }, + { + "id": 10751, + "name": "가족" + }, + { + "id": 14, + "name": "판타지" + }, + { + "id": 36, + "name": "역사" + }, + { + "id": 27, + "name": "공포" + }, + { + "id": 10402, + "name": "음악" + }, + { + "id": 9648, + "name": "미스터리" + }, + { + "id": 10749, + "name": "로맨스" + }, + { + "id": 878, + "name": "SF" + }, + { + "id": 10770, + "name": "TV 영화" + }, + { + "id": 53, + "name": "스릴러" + }, + { + "id": 10752, + "name": "전쟁" + }, + { + "id": 37, + "name": "서부" + } + ] +} diff --git a/index.html b/index.html index 3c02d0a403..e1cb22b0d3 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,8 @@ + + 영화 리뷰 @@ -42,7 +44,7 @@

- +
@@ -52,6 +54,61 @@

+ + + diff --git a/src/api.ts b/src/api.ts index d49412b5ba..a1add82130 100644 --- a/src/api.ts +++ b/src/api.ts @@ -7,10 +7,30 @@ interface MoviesResponse { total_results: number; } +export interface Genre { + id: number; + name: string; +} + +function handleResponseError(response: Response) { + if (!response.ok) { + if (response.status === 401) { + throw new Error("인증에 실패했습니다. API 키를 확인해주세요."); + } + if (response.status === 404) { + throw new Error("요청한 정보를 찾을 수 없습니다"); + } + if (response.status >= 500) { + throw new Error("오류가 발생했습니다. 다시 시도해주세요."); + } + } +} + export interface Movie { adult: boolean; backdrop_path: string; genre_ids: number[]; + genres: string[]; id: number; original_language: "en-US"; original_title: string; @@ -27,21 +47,23 @@ export interface Movie { const API_PATH = { POPULAR_MOVIE: "https://api.themoviedb.org/3/movie/popular", SEARCH_MOVIE: "https://api.themoviedb.org/3/search/movie", + GENRE: "https://api.themoviedb.org/3/genre/movie/list", +}; + +const defaultOptions = { + method: "GET", + headers: { + accept: "application/json", + Authorization: `Bearer ${API_KEY}`, + }, }; export async function getPopularMovies( pageNum: number, ): Promise { const url = `${API_PATH.POPULAR_MOVIE}?page=${pageNum}&language=ko-KR`; - const options = { - method: "GET", - headers: { - accept: "application/json", - Authorization: `Bearer ${API_KEY}`, - }, - }; - const response = await fetch(url, options); - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + const response = await fetch(url, defaultOptions); + handleResponseError(response); return response.json(); } @@ -50,14 +72,14 @@ export async function getSearchMovies( pageNum: number, ): Promise { const url = `${API_PATH.SEARCH_MOVIE}?query=${query}&page=${pageNum}&language=ko-KR`; - const options = { - method: "GET", - headers: { - accept: "application/json", - Authorization: `Bearer ${API_KEY}`, - }, - }; - const response = await fetch(url, options); - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + const response = await fetch(url, defaultOptions); + handleResponseError(response); + return response.json(); +} + +export async function getGenres(): Promise<{ genres: Genre[] }> { + const url = `${API_PATH.GENRE}?language=ko-KR`; + const response = await fetch(url, defaultOptions); + handleResponseError(response); return response.json(); } diff --git a/src/component.ts b/src/component.ts index 8b40a54d25..9dc4bc366d 100644 --- a/src/component.ts +++ b/src/component.ts @@ -3,15 +3,12 @@ import starEmptyImg from "./images/star_empty.png"; import noImagePlanetImg from "./images/no_image_planet.png"; import screamingPlanetImg from "./images/screaming_planet.svg"; import planetAndStarImg from "./images/planet_and_star.png"; - -const IMAGE_PATH = "https://image.tmdb.org/t/p/original"; +import { IMAGE_PATH } from "./constants/movie"; const Component = { movie(movieData: Pick) { const { poster_path, title, vote_average } = movieData; - const src = poster_path - ? `${IMAGE_PATH}/${poster_path}` - : noImagePlanetImg; + const src = poster_path ? `${IMAGE_PATH}/${poster_path}` : noImagePlanetImg; return `
  • diff --git a/src/constans/movie.ts b/src/constans/movie.ts deleted file mode 100644 index b85beed5d5..0000000000 --- a/src/constans/movie.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const ONCE_MOVIE_LIMIT = 20; -export const INITIAL_PAGE_NUM = 1; diff --git a/src/constants/movie.ts b/src/constants/movie.ts new file mode 100644 index 0000000000..4a066d35e3 --- /dev/null +++ b/src/constants/movie.ts @@ -0,0 +1,13 @@ +export const ONCE_MOVIE_LIMIT = 20; +export const INITIAL_PAGE_NUM = 1; + +export const IMAGE_PATH = "https://image.tmdb.org/t/p/original"; + +export const RATING_STRING: Record = { + 2: "최악이에요", + 4: "별로에요", + 6: "보통이에요", + 8: "재미있어요", + 10: "명작이에요", +}; + diff --git a/src/images/modal_button_close.png b/src/images/modal_button_close.png new file mode 100644 index 0000000000..b352a220dc Binary files /dev/null and b/src/images/modal_button_close.png differ diff --git a/src/main.ts b/src/main.ts index a28f1bb78b..f28af0a9e4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,90 +1,3 @@ -import { getPopularMovies, getSearchMovies } from "./api.ts"; -import { MovieRenderer, Renderer } from "./render.ts"; -import { ONCE_MOVIE_LIMIT, INITIAL_PAGE_NUM } from "./constans/movie.ts"; -import State from "./state.ts"; +import { Index } from "./pages/index.ts"; -async function loadInitialMovie() { - const app = document.querySelector("#app"); - if (app) { - Renderer.renderSkeleton( - ".thumbnail-list", - State.getRequestMovieCount() || ONCE_MOVIE_LIMIT, - ); - try { - const { results: movies, page } = - await getPopularMovies(INITIAL_PAGE_NUM); - State.setNextPageNum(page + 1); - State.setRequestMovieCount(movies.length); - MovieRenderer.renderInitialMovies(movies); - const loadMoreButton = document.querySelector(".load-more-button"); - if (loadMoreButton) - loadMoreButton.addEventListener("click", loadMoreMovies); - } catch (err) { - MovieRenderer.renderError(); - } - } -} - -async function loadMoreMovies() { - Renderer.renderSkeleton(".thumbnail-list", State.getRequestMovieCount()); - Renderer.hideLoadMoreButton(); - try { - const { results: movies, page } = await getPopularMovies( - State.getNextPageNum(), - ); - State.setNextPageNum(page + 1); - MovieRenderer.renderLoadMoreMovies(movies); - } catch (err) { - MovieRenderer.renderError(); - Renderer.clearBanner(); - } -} - -async function loadSearchMovies(query: string) { - Renderer.renderSkeleton(".thumbnail-list", State.getRequestMovieCount()); - Renderer.hideLoadMoreButton(); - try { - const { results: movies, page } = await getSearchMovies( - query, - INITIAL_PAGE_NUM, - ); - const loadMoreButton = document.querySelector(".load-more-button"); - MovieRenderer.renderSearchResult(movies, query); - State.setNextSearchPageNum(page + 1); - if (loadMoreButton) { - loadMoreButton.removeEventListener("click", loadMoreMovies); - loadMoreButton.addEventListener("click", () => - loadMoreSearchMovies(query), - ); - } - } catch (err) { - MovieRenderer.renderError(); - } -} - -async function loadMoreSearchMovies(query: string) { - Renderer.renderSkeleton(".thumbnail-list", State.getRequestMovieCount()); - Renderer.hideLoadMoreButton(); - try { - const { results: movies, page } = await getSearchMovies( - query, - State.getNextSearchPageNum(), - ); - State.setNextSearchPageNum(page + 1); - MovieRenderer.renderLoadMoreSearchMovies(movies); - } catch (err) { - MovieRenderer.renderError(); - } -} - -const searchForm = document.querySelector(".search-form"); -searchForm?.addEventListener("submit", (event) => { - event.preventDefault(); - const input = searchForm.querySelector("input"); - if (input) { - const searchValue = input.value; - loadSearchMovies(searchValue); - } -}); - -addEventListener("load", loadInitialMovie); +Index.init(); diff --git a/src/pages/index.ts b/src/pages/index.ts new file mode 100644 index 0000000000..87035ccb38 --- /dev/null +++ b/src/pages/index.ts @@ -0,0 +1,109 @@ +import { getPopularMovies, getGenres } from "../api.ts"; +import { IndexRenderer, Renderer } from "../render.ts"; +import { ONCE_MOVIE_LIMIT, INITIAL_PAGE_NUM } from "../constants/movie.ts"; +import { Search } from "./search.ts"; +import { MovieDetail } from "./movieDetail.ts"; +import State from "../state.ts"; + +export const Index = { + init() { + this.setUpInitialContent(); + this.setUpEventListeners(); + }, + + setUpInitialContent() { + addEventListener("load", () => this.showPopularMovies()); + }, + + setUpEventListeners() { + this.setUpLoadMoreMovies(); + this.setUpSearchForm(); + MovieDetail.setUpEventListeners(); + }, + + setUpLoadMoreMovies() { + const endOfThumbnailList = document.querySelector("#end-of-thumbnail-list"); + if (endOfThumbnailList) { + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting) return; + if (State.isLoading) return; + const query = State.searchQuery; + const nextPage = query ? State.nextSearchPageNum : State.nextPageNum; + const totalPage = query ? State.totalSearchPages : State.totalPages; + if (totalPage === 0 || nextPage > totalPage) return; + this.handleLoadMoreMovies(); + }, + { threshold: 0.8 }, + ); + observer.observe(endOfThumbnailList); + } + }, + + setUpSearchForm() { + const searchForm = document.querySelector(".search-form"); + searchForm?.addEventListener("submit", (event) => { + event.preventDefault(); + const input = searchForm.querySelector("input"); + if (input) { + const searchValue = input.value; + Search.showSearchMovies(searchValue); + } + }); + }, + + handleLoadMoreMovies() { + const query = State.searchQuery; + if (query) { + Search.showMoreSearchMovies(query); + } else { + this.showMoreMovies(); + } + }, + + async showPopularMovies() { + const app = document.querySelector("#app"); + if (app) { + State.isLoading = true; + Renderer.renderSkeleton( + ".thumbnail-list", + State.requestMovieCount || ONCE_MOVIE_LIMIT, + ); + try { + const [{ results: movies, page, total_pages }, { genres }] = + await Promise.all([getPopularMovies(INITIAL_PAGE_NUM), getGenres()]); + State.nextPageNum = page + 1; + State.totalPages = total_pages; + State.requestMovieCount = movies.length; + State.genres = genres; + IndexRenderer.renderInitialMovies(movies); + MovieDetail.setUpMovieDetail(movies); + } catch (err) { + Renderer.renderError(err); + } finally { + State.isLoading = false; + } + } + }, + + async showMoreMovies() { + State.isLoading = true; + Renderer.renderSkeleton(".thumbnail-list", State.requestMovieCount); + try { + const { + results: movies, + page, + total_pages, + } = await getPopularMovies(State.nextPageNum); + State.nextPageNum = page + 1; + State.totalPages = total_pages; + Renderer.renderLoadMoreMovies(movies); + MovieDetail.setUpMovieDetail(movies); + } catch (err) { + Renderer.renderError(err); + Renderer.clearBanner(); + } finally { + State.isLoading = false; + } + }, +}; diff --git a/src/pages/movieDetail.ts b/src/pages/movieDetail.ts new file mode 100644 index 0000000000..774f851261 --- /dev/null +++ b/src/pages/movieDetail.ts @@ -0,0 +1,75 @@ +import { Movie } from "../api.ts"; +import { MovieDetailRenderer } from "../render.ts"; +import State from "../state.ts"; + +export const MovieDetail = { + setUpEventListeners() { + this.setUpDialogCloser(); + this.setUpMyRatingToMovie(); + }, + + setUpDialogCloser() { + const dialog = document.querySelector("dialog"); + const dialogCloser = document.querySelector("#closeModal"); + dialogCloser?.addEventListener("click", () => { + if (dialog) dialog.close(); + }); + }, + + setUpMovieDetail(moviesData: Movie[]) { + // 이미 렌더링된 영화는 제외한다. + const movieList = [ + ...document.querySelectorAll(".thumbnail-list li"), + ].slice(-moviesData.length); + movieList.forEach((movie, idx) => { + movie.addEventListener("click", (e) => { + e.preventDefault(); + const dialog = document.querySelector("dialog"); + const movieData = moviesData[idx]; + const [movieGenres, rating] = this.extractDetailMovieData(movieData); + MovieDetailRenderer.renderMovieDetail( + movieData, + new Date(movieData.release_date).getFullYear(), + movieGenres, + rating, + ); + dialog?.showModal(); + }); + }); + }, + + setUpMyRatingToMovie() { + const ratingButtons = document.querySelectorAll( + ".modal .my-rating-container button", + ); + ratingButtons.forEach((button) => { + button.addEventListener("click", (e) => { + e.preventDefault(); + const myRating = (e.currentTarget as HTMLElement).dataset.rating; + const movieContainer = document.querySelector( + "#movie-detail-container", + ); + const movieId = (movieContainer as HTMLElement).dataset.movieId; + this.setMyRating(`movie-${movieId}-my-rating`, String(myRating)); + MovieDetailRenderer.renderMyRating(myRating ? Number(myRating) : 0); + }); + }); + }, + + extractDetailMovieData(movie: Movie): [string[], number] { + const genres = State.genres; + const movieGenres = movie.genre_ids.map( + (genreId) => genres.find((genre) => genre.id === genreId)!.name, + ); + const currentRating = this.getMyRating(`movie-${movie.id}-my-rating`); + return [movieGenres, currentRating]; + }, + + getMyRating(key: string) { + return Number(localStorage.getItem(key)); + }, + + setMyRating(key: string, rating: string) { + localStorage.setItem(key, rating); + }, +}; diff --git a/src/pages/search.ts b/src/pages/search.ts new file mode 100644 index 0000000000..4fa0573f87 --- /dev/null +++ b/src/pages/search.ts @@ -0,0 +1,48 @@ +import { getSearchMovies } from "../api.ts"; +import { Renderer, SearchRenderer } from "../render.ts"; +import { INITIAL_PAGE_NUM } from "../constants/movie.ts"; +import { MovieDetail } from "./movieDetail.ts"; +import State from "../state.ts"; + +export const Search = { + async showSearchMovies(query: string) { + State.isLoading = true; + Renderer.renderSkeleton(".thumbnail-list", State.requestMovieCount); + try { + const { + results: movies, + page, + total_pages, + } = await getSearchMovies(query, INITIAL_PAGE_NUM); + SearchRenderer.renderSearchResult(movies, query); + State.nextSearchPageNum = page + 1; + State.totalSearchPages = total_pages; + State.searchQuery = query; + MovieDetail.setUpMovieDetail(movies); + } catch (err) { + Renderer.renderError(err); + } finally { + State.isLoading = false; + } + }, + + async showMoreSearchMovies(query: string) { + State.isLoading = true; + Renderer.renderSkeleton(".thumbnail-list", State.requestMovieCount); + try { + const { + results: movies, + page, + total_pages, + } = await getSearchMovies(query, State.nextSearchPageNum); + State.nextSearchPageNum = page + 1; + State.totalSearchPages = total_pages; + Renderer.renderLoadMoreMovies(movies); + MovieDetail.setUpMovieDetail(movies); + } catch (err) { + Renderer.renderError(err); + } finally { + State.isLoading = false; + } + }, +}; diff --git a/src/render.ts b/src/render.ts index d2f52c1333..7ad3ea723c 100644 --- a/src/render.ts +++ b/src/render.ts @@ -1,9 +1,11 @@ import type { Movie } from "./api.ts"; import Component from "./component.ts"; -import { ONCE_MOVIE_LIMIT } from "./constans/movie.ts"; import { observeHeaderScroll } from "./observer.ts"; +import { IMAGE_PATH, RATING_STRING } from "./constants/movie.ts"; +import starFilledImg from "./images/star_filled.png"; +import starEmptyImg from "./images/star_empty.png"; -export const MovieRenderer = { +export const IndexRenderer = { renderInitialMovies(movies: Movie[]) { const movieList = document.querySelector(".thumbnail-list"); const banner = document.querySelector(".banner-container"); @@ -15,50 +17,26 @@ export const MovieRenderer = { Renderer.clearSkeleton(movieList); Renderer.renderMovies(movieList, movies); } - Renderer.renderSectionHeading(); + this.renderSectionHeading(); }, - renderLoadMoreMovies(movies: Movie[]) { - const movieList = document.querySelector(".thumbnail-list"); - const haveRestPage = movies.length === ONCE_MOVIE_LIMIT; - if (haveRestPage) Renderer.showLoadMoreButton(); - if (movieList) { - Renderer.clearSkeleton(movieList); - Renderer.renderMovies(movieList, movies); + + renderSectionHeading() { + const heading = document.querySelector("section > h2"); + if (heading instanceof HTMLElement) { + heading.innerHTML = `지금 인기 있는 영화`; } }, +}; + +export const SearchRenderer = { renderSearchResult(movies: Movie[], query: string) { - const haveRestPage = movies.length === ONCE_MOVIE_LIMIT; + const movieList = document.querySelector(".thumbnail-list"); Renderer.clearBanner(); Renderer.clearMovies(); Renderer.clearEmptyResult(); - Renderer.renderSearchSectionHeading(query); - if (haveRestPage) Renderer.showLoadMoreButton(); + this.renderSearchSectionHeading(query); if (movies.length === 0) Renderer.renderEmptyResult(); - else Renderer.renderSearchMovies(movies); - }, - renderLoadMoreSearchMovies(movies: Movie[]) { - const movieList = document.querySelector(".thumbnail-list"); - const haveRestPage = movies.length === ONCE_MOVIE_LIMIT; - if (haveRestPage) Renderer.showLoadMoreButton(); - if (movieList) { - Renderer.clearSkeleton(movieList); - Renderer.renderSearchMovies(movies); - } - }, - - renderError() { - const content = document.querySelector(".thumbnail-list"); - if (content) - Renderer.renderError(content, "영화 정보를 불러오는 데 실패했습니다."); - }, -}; - -export const Renderer = { - renderSectionHeading() { - const heading = document.querySelector("section > h2"); - if (heading instanceof HTMLElement) { - heading.innerHTML = `지금 인기 있는 영화`; - } + else if (movieList) Renderer.renderMovies(movieList, movies); }, renderSearchSectionHeading(title: string) { @@ -68,12 +46,80 @@ export const Renderer = { heading.style.marginTop = "12rem"; } }, +}; - renderSearchMovies(movies: Movie[]) { - const movieList = document.querySelector(".thumbnail-list"); - if (movieList) { - this.renderMovies(movieList, movies); - } +export const MovieDetailRenderer = { + renderMovieDetail( + movieData: Movie, + releaseYear: number, + genres: string[], + rating: number, + ) { + const { id, title, poster_path, vote_average, overview } = movieData; + const movieContainer = document.querySelector( + "#movie-detail-container", + ); + const movieTitle = document.querySelector("#movie-detail-title"); + const moviePoster = document.querySelector("#movie-detail-poster"); + const movieVoteAverage = document.querySelector( + "#movie-detail-vote-average", + ); + const movieOverview = document.querySelector("#movie-detail-overview"); + const movieReleaseYear = document.querySelector( + "#movie-detail-release-year", + ); + const movieGenres = document.querySelector("#movie-detail-category"); + if ( + !movieTitle || + !movieVoteAverage || + !movieOverview || + !movieReleaseYear || + !movieGenres + ) + return; + if (movieContainer) movieContainer.dataset.movieId = String(id); + movieTitle.innerHTML = title; + if (moviePoster instanceof HTMLImageElement) + moviePoster.src = `${IMAGE_PATH}/${poster_path}`; + movieVoteAverage.innerHTML = vote_average.toFixed(1); + movieOverview.innerHTML = overview; + movieReleaseYear.innerHTML = String(releaseYear); + movieGenres.innerHTML = genres.join(", "); + this.renderMyRating(rating); + }, + + renderMyRating(rating: number) { + const myRating = document.querySelectorAll("#my-rating button"); + const myRatingToString = document.querySelector("#my-rating-to-string"); + const myRatingRatio = document.querySelector("#my-rating-ratio"); + myRating.forEach((button) => { + const backgroundImage = button.querySelector("img"); + if (!backgroundImage) return; + if (rating >= Number((button as HTMLElement).dataset.rating)) { + backgroundImage.src = starFilledImg; + } else { + backgroundImage.src = starEmptyImg; + } + }); + if (myRatingToString) + myRatingToString.innerHTML = RATING_STRING[rating] ?? ""; + if (myRatingRatio instanceof HTMLElement) + myRatingRatio.innerHTML = `(${rating}/${Object.keys(RATING_STRING).slice(-1)})`; + }, + + clearMovieDetail() { + const modal = document.querySelector("#modalBackground"); + modal?.remove(); + document.body.classList.remove("modal-open"); + }, +}; + +export const Renderer = { + renderMovies(parent: Element, movies: Movie[]) { + const movieListComponent = movies + .map((movie) => Component.movie(movie)) + .join(""); + parent.insertAdjacentHTML("beforeend", movieListComponent); }, clearMovies() { @@ -106,48 +152,33 @@ export const Renderer = { emptyResult?.remove(); }, - renderError(parent: Element, message: string) { - parent.innerHTML = Component.error(message); - }, - renderSkeleton(selector: string, length: number) { const target = document.querySelector(selector); if (target instanceof HTMLElement) { - target.innerHTML += Array.from({ length: length }) - .map(() => Component.movieSkeleton()) - .join(""); + target.insertAdjacentHTML( + "beforeend", + Array.from({ length: length }) + .map(() => Component.movieSkeleton()) + .join(""), + ); } }, - renderMovies(parent: Element, movies: Movie[]) { - const movieListComponent = movies - .map((movie) => Component.movie(movie)) - .join(""); - parent.innerHTML += movieListComponent; - }, - clearSkeleton(parent: Element) { - parent.innerHTML = [...parent.children] - .filter((child) => { - if ( - child instanceof HTMLElement && - child.classList.contains("skeleton") - ) { - return false; - } - return true; - }) - .map((child) => child.outerHTML) - .join(""); + parent.querySelectorAll(".skeleton").forEach((el) => el.remove()); }, - showLoadMoreButton() { - const button = document.querySelector(".load-more-button"); - if (button instanceof HTMLElement) button.style.display = "block"; + renderLoadMoreMovies(movies: Movie[]) { + const movieList = document.querySelector(".thumbnail-list"); + if (movieList) { + Renderer.clearSkeleton(movieList); + Renderer.renderMovies(movieList, movies); + } }, - hideLoadMoreButton() { - const button = document.querySelector(".load-more-button"); - if (button instanceof HTMLElement) button.style.display = "none"; + renderError(err: unknown) { + const message = err instanceof Error ? err.message : "에러가 발생했습니다."; + const content = document.querySelector(".thumbnail-list"); + if (content) content.innerHTML = Component.error(message); }, }; diff --git a/src/state.ts b/src/state.ts index 30b52a0d53..bc9ded003a 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,31 +1,14 @@ -class State { - #nextPageNum = 0; - #nextSearchPageNum = 0; - #requestMovieCount = 0; - - getNextPageNum() { - return this.#nextPageNum; - } - - getNextSearchPageNum() { - return this.#nextSearchPageNum; - } - - getRequestMovieCount() { - return this.#requestMovieCount; - } - - setNextPageNum(page: number) { - this.#nextPageNum = page; - } - - setNextSearchPageNum(page: number) { - this.#nextSearchPageNum = page; - } - - setRequestMovieCount(count: number) { - this.#requestMovieCount = count; - } -} - -export default new State(); +import type { Genre } from "./api.ts"; + +const State = { + nextPageNum: 0, + nextSearchPageNum: 0, + requestMovieCount: 0, + searchQuery: "", + genres: [] as Genre[], + isLoading: false, + totalPages: 0, + totalSearchPages: 0, +}; + +export default State; diff --git a/src/styles/main.css b/src/styles/main.css index f384635c5d..9ce54dc1c4 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -37,6 +37,8 @@ button.primary { font-weight: bold; background-color: var(--color-lightblue-90); border-radius: 4px; + width: 80px; + padding: 10px 0; } #wrap { @@ -162,6 +164,9 @@ span.rate-value { .top-rated-movie > .container { padding-block: 200px; + display: flex; + flex-direction: column; + gap: 12px; } .title { diff --git a/src/styles/modal.css b/src/styles/modal.css new file mode 100644 index 0000000000..5625834239 --- /dev/null +++ b/src/styles/modal.css @@ -0,0 +1,128 @@ +@import "./colors.css"; + +/* modal.css */ +body.modal-open { + overflow: hidden; +} + +.modal-background { + display: none; +} + +.modal-background[open] { + position: fixed; + margin: 0; + top: 0; + left: 0; + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + background-color: rgba(0, 0, 0, 0.5); /* 반투명 배경을 위해 설정 */ + backdrop-filter: blur(10px); /* 블러 효과 적용 */ + display: flex; + justify-content: center; + align-items: center; + z-index: 10; + transition: + opacity 0.3s ease, + visibility 0.3s ease; +} + +.modal { + background-color: var(--color-bluegray-90); + padding: 20px; + border-radius: 16px; + color: white; + z-index: 2; + position: relative; + width: 1000px; +} + +.close-modal { + position: absolute; + margin: 0; + padding: 0; + top: 24px; + right: 24px; + background: none; + border: none; + color: white; + font-size: 20px; + cursor: pointer; +} + +.modal-container { + display: flex; +} + +.modal-image img { + width: 380px; + border-radius: 16px; +} + +.modal-description { + width: 100%; + padding: 8px; + margin-left: 16px; + line-height: 1.6rem; +} + +.modal-description .rate > img { + position: relative; + top: 5px; +} + +.modal-description > *:not(:last-child) { + margin-bottom: 8px; +} + +.modal-description h2 { + font-size: 2rem; + margin: 0 0 8px; +} + +.detail { + max-height: 430px; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.modal .average-container { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.modal .category { + margin: 16px 0; +} + +.modal .detail-container dt, +.modal .my-rating-container dt, +.modal .my-rating-container dd { + font-size: 24px; + font-weight: 600; + margin: 16px 0; +} + +.modal .my-rating-container dd { + display: flex; + align-items: center; + gap: 8px; +} + +.modal .my-rating-container dd button { + background-color: transparent; + padding: 0; +} + +.modal .my-rating-container dd button img { + width: 32px; + height: 32px; +} + +.modal .my-rating-container .rating-ratio { + color: var(--color-bluegray-30); +} diff --git a/src/styles/responsive.css b/src/styles/responsive.css new file mode 100644 index 0000000000..d4e3eb8344 --- /dev/null +++ b/src/styles/responsive.css @@ -0,0 +1,76 @@ +@media (max-width: 1024px) { + .thumbnail-list { + grid-template-columns: repeat(4, 200px); + } + + .header-container { + flex-direction: column; + gap: 12px; + } + + h1.logo { + position: static; + } +} + +@media (max-width: 768px) { + .thumbnail-list { + grid-template-columns: repeat(3, 200px); + } + + .modal-background[open] { + align-items: flex-end; + padding: 0; + } + + .modal { + border-radius: 0; + } + + .modal-container { + flex-direction: column; + align-items: center; + gap: 12px; + } + + #movie-detail-poster { + width: 200px; + } + + #movie-detail-title, + .modal .category { + text-align: center; + } + + .modal .average-container { + justify-content: center; + } +} + +@media (max-width: 640px) { + .thumbnail-list { + grid-template-columns: repeat(1, 200px); + } + + .top-rated-movie .container { + padding-left: 24px; + padding-right: 24px; + } + + #movie-detail-poster { + display: none; + } + + .modal .my-rating-container, + .modal .detail-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + + .modal .my-rating-container dd { + display: flex; + flex-direction: column; + } +}