diff --git a/README.md b/README.md index d387c7679..fab1c990c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ - [x] 지금 인기 있는 영화 목록 20개를 보여준다. - [x] 제일 먼저 있는 영화를 배너로 띄운다. - [x] 더보기 버튼을 누르면 20개의 영화 목록을 추가로 불러온다. +- [x] 더보기 버튼을 무한 스크롤로 대체한다. - [x] 불러올 영화 목록이 없으면 더보기 버튼이 사라진다. +- [x] 영화 데이터를 받아오기 전에 스켈레톤 UI를 먼저 띄운다. ## 검색 시 UI @@ -17,18 +19,32 @@ - [x] 해당 검색어에 대한 영화 목록 20개를 보여준다. - [x] 검색어를 검색 제목에 띄운다. - [x] 더보기 버튼을 누르면 20개의 검색과 관련된 영화 목록을 추가로 불러온다. +- [x] 더보기 버튼을 무한 스크롤로 대체한다. - [x] 불러올 영화 목록이 없으면 더보기 버튼이 사라진다. - [x] 검색 결과가 없으면 `검색 결과가 없습니다.` 라는 문구를 띄운다. +- [x] 영화 데이터를 받아오기 전에 스켈레톤 UI를 먼저 띄운다. + +## 모달 UI + +- [x] 영화 항목을 선택하면 모달 UI를 띄운다. +- [x] 선택한 영화의 상세 정보 api를 호출한다. +- [x] 해당 영화에 대한 상세 정보를 보여준다. +- [x] 별점을 누르면 해당 별점에 맞게 나의 점수가 저장된다. +- [x] x 를 누르면 모달이 닫힌다. +- [x] esc 를 누르면 모달이 닫힌다. +- [x] 모달 바깥을 누르면 모달이 닫힌다. +- [x] 영화 데이터를 받아오기 전에 스켈레톤 UI를 먼저 띄운다. ## 예외 처리 - [x] 기본 UI에서 영화 정보를 불러오지 못했다면 `영화 정보를 불러오지 못했습니다. 다시 시도해주세요.` 라는 문구를 띄운다. - [x] 영화 이미지를 불러오지 못했다면 `No Image` 이미지를 띄운다. - [x] 검색 시, 영화 정보를 불러오지 못했다면 `영화 검색 결과를 불러오지 못했습니다. 다시 시도해주세요.` 라는 문구를 띄운다. +- [x] 영화 상세 정보를 불러올 시, 정보를 불러오지 못했다면 에러 UI를 출력한다. ## 공통 -- [x] 영화 데이터를 받아오기 전에 스켈레톤 UI를 먼저 띄운다. +- [ ] pc, 태블릿, 휴대폰에서 사용가능하도록 반응형 UI를 만든다. ## API 명세 diff --git a/cypress/e2e/home.cy.ts b/cypress/e2e/home.cy.ts index fef89e954..10a1281df 100644 --- a/cypress/e2e/home.cy.ts +++ b/cypress/e2e/home.cy.ts @@ -1,104 +1,95 @@ describe("홈 화면 테스트", () => { beforeEach(() => { - cy.intercept("GET", "**/movie/popular?language=ko-KR®ion=KR&page=1").as( - "getMovies", - ); - cy.visit("http://localhost:5173"); - }); + cy.intercept("GET", "**/movie/popular?language=ko-KR®ion=KR&page=1", { + statusCode: 200, + body: { + page: 1, + results: Array.from({ length: 20 }, (_, index) => ({ + id: index + 1, + title: `영화 ${index + 1}`, + poster_path: `/poster-${index + 1}.jpg`, + vote_average: 7.5, + })), + total_pages: 2, + total_results: 40, + }, + }).as("page1"); - it("API 호출 확인", () => { - cy.wait("@getMovies").its("response.body.results").should("be.an", "array"); + cy.visit("http://localhost:5173"); }); - it("배너 안의 요소를 확인", () => { - cy.wait("@getMovies") - .its("response.body.results") - .then((results) => { - cy.get(".title").should("contain", results[0].title); - cy.get(".rate-value").should("contain", results[0].vote_average); - cy.get(".background-container") - .invoke("css", "background-image") - .should("include", results[0].poster_path); - }); + it("초기 진입 시 영화 목록이 렌더된다.", () => { + cy.wait("@page1"); + cy.get(".thumbnail-item").should("have.length", 20); }); - it("리스트 안의 요소를 확인", () => { - cy.wait("@getMovies") - .its("response.body.results") - .then((results) => { - cy.get(".thumbnail").each(($el, index) => { - cy.wrap($el) - .should("have.attr", "src") - .and("include", results[index].poster_path); - }); + it("초기 진입 시 베너가 렌더된다.", () => { + cy.wait("@page1"); - cy.get(".item-rate").each(($el, index) => { - cy.wrap($el).should("contain", results[index].vote_average); - }); - - cy.get(".item-title").each(($el, index) => { - cy.wrap($el).should("contain", results[index].title); - }); - }); + cy.get(".top-rated-movie").should("be.visible"); + cy.get(".title").should("contain", "영화 1"); + cy.get(".rate-value").should("contain", "7.5"); }); - it("더보기 버튼이 있는지 확인", () => { - cy.get(".thumbnail-add-button").should("exist"); + it("배너의 자세히 보기 버튼 클릭 시 모달이 열린다.", () => { + cy.wait("@page1"); + + cy.get(".top-rated-movie .detail").click(); + cy.get(".modal-background").should("have.class", "active"); + cy.get(".modal").should("be.visible"); }); }); -describe("더보기 버튼 테스트", () => { +describe("영화 리스트 기능 테스트", () => { beforeEach(() => { - cy.intercept("GET", "**/movie/popular?language=ko-KR®ion=KR&page=1").as( - "page1", - ); - cy.intercept("GET", "**/movie/popular?language=ko-KR®ion=KR&page=2").as( - "page2", - ); + cy.intercept("GET", "**/movie/popular?language=ko-KR®ion=KR&page=1", { + statusCode: 200, + body: { + page: 1, + results: Array.from({ length: 20 }, (_, index) => ({ + id: index + 1, + title: `영화 ${index + 1}`, + poster_path: `/poster-${index + 1}.jpg`, + vote_average: 7.5, + })), + total_pages: 2, + total_results: 40, + }, + }).as("page1"); + + cy.intercept("GET", "**/movie/popular?language=ko-KR®ion=KR&page=2", { + statusCode: 200, + body: { + page: 2, + results: Array.from({ length: 20 }, (_, index) => ({ + id: index + 21, + title: `영화 ${index + 21}`, + poster_path: `/poster-${index + 21}.jpg`, + vote_average: 8.1, + })), + total_pages: 2, + total_results: 40, + }, + }).as("page2"); cy.visit("http://localhost:5173"); }); - it("버튼 클릭 시 API 호출 확인", () => { - cy.get(".thumbnail-add-button").click(); + it("영화 카드 클릭 시 모달이 열린다.", () => { + cy.wait("@page1"); - cy.wait("@page2").its("response.body.results").should("be.an", "array"); + cy.get(".item").first().click(); + cy.get(".modal-background").should("have.class", "active"); + cy.get(".modal").should("be.visible"); }); - it("리스트 안의 요소를 확인", () => { - let page1Results: Movies[]; - - cy.wait("@page1") - .its("response.body.results") - .then((results1) => { - page1Results = results1; - }); - - cy.get(".thumbnail-add-button").click(); - - cy.wait("@page2") - .its("response.body.results") - .then((results2) => { - const allResults = [...page1Results, ...results2]; - - cy.get(".thumbnail").should("have.length", allResults.length); - - cy.get(".thumbnail").each(($el, index) => { - cy.wrap($el) - .should("have.attr", "src") - .and("include", allResults[index].poster_path); - }); + it("무한 스크롤 시 다음 페이지가 붙는다.", () => { + cy.wait("@page1"); + cy.get(".thumbnail-item").should("have.length", 20); - cy.get(".item-title").each(($el, index) => { - cy.wrap($el).should("contain", allResults[index].title); - }); + cy.get("#sentinel").scrollIntoView(); + cy.wait("@page2"); - cy.get(".item-rate").each(($el, index) => { - cy.wrap($el).should( - "contain", - String(allResults[index].vote_average), - ); - }); - }); + cy.get(".thumbnail-item").should("have.length", 40); }); }); diff --git a/cypress/e2e/modal.cy.ts b/cypress/e2e/modal.cy.ts new file mode 100644 index 000000000..13a6b73ff --- /dev/null +++ b/cypress/e2e/modal.cy.ts @@ -0,0 +1,75 @@ +describe("모달 테스트", () => { + beforeEach(() => { + cy.intercept("GET", "**/movie/popular?language=ko-KR®ion=KR&page=1", { + statusCode: 200, + body: { + page: 1, + results: Array.from({ length: 20 }, (_, index) => ({ + id: index + 1, + title: `영화 ${index + 1}`, + poster_path: `/poster-${index + 1}.jpg`, + vote_average: 7.5, + })), + total_pages: 2, + total_results: 40, + }, + }).as("page1"); + + cy.intercept("GET", "**/movie/1?language=ko-KR®ion=KR", { + statusCode: 200, + body: { + id: 1, + title: "영화 1", + poster_path: "/poster-1.jpg", + vote_average: 7.5, + release_date: "2024-01-01", + genres: [ + { id: 1, name: "액션" }, + { id: 2, name: "모험" }, + ], + overview: "영화 1의 줄거리입니다.", + }, + }).as("movieDetail"); + + cy.visit("http://localhost:5173"); + cy.wait("@page1"); + cy.get(".top-rated-movie .detail").click(); + cy.wait("@movieDetail"); + }); + + it("모달이 열리면 상세정보가 렌더된다.", () => { + cy.get(".modal-background").should("have.class", "active"); + cy.get(".modal").should("be.visible"); + cy.get(".modal .modal-title-section h2").should("contain", "영화 1"); + cy.get(".modal .detail").should("contain", "영화 1의 줄거리입니다."); + }); + + it("x 버튼을 누르면 모달이 닫힌다.", () => { + cy.get(".close-modal").click(); + cy.get(".modal-background").should("not.have.class", "active"); + cy.get(".modal").should("not.be.visible"); + }); + + it("esc 버튼을 누르면 모달이 닫힌다.", () => { + cy.get("body").type("{esc}"); + cy.get(".modal-background").should("not.have.class", "active"); + cy.get(".modal").should("not.be.visible"); + }); + + it("모달 바깥을 누르면 모달이 닫힌다.", () => { + cy.get(".modal-background").click("topLeft"); + cy.get(".modal-background").should("not.have.class", "active"); + cy.get(".modal").should("not.be.visible"); + }); + + it("처음 모달이 열리면 별점 초기값이 보인다.", () => { + cy.get(".myrate-comment").should("contain", "별점을 입력해주세요"); + cy.get(".myrate-score").should("contain", "(0/10)"); + }); + + it("3번째 별을 클릭하면 별점이 반영된다.", () => { + cy.get(".myrate-stars img").eq(2).click(); + cy.get(".myrate-comment").should("contain", "보통이에요"); + cy.get(".myrate-score").should("contain", "(6/10)"); + }); +}); diff --git a/cypress/e2e/search.cy.ts b/cypress/e2e/search.cy.ts index 5763af5d6..e04070615 100644 --- a/cypress/e2e/search.cy.ts +++ b/cypress/e2e/search.cy.ts @@ -1,42 +1,70 @@ describe("검색 데이터 있을 때 테스트", () => { beforeEach("검색어를 입력하고 검색 버튼을 클릭한다.", () => { - cy.intercept("GET", "**/search/movie*").as("searchMovies"); + cy.intercept("GET", "**/search/movie*", { + statusCode: 200, + body: { + page: 1, + results: [ + { + id: 101, + title: "해리 포터와 마법사의 돌", + poster_path: "/harry1.jpg", + vote_average: 7.8, + }, + { + id: 102, + title: "해리 포터와 비밀의 방", + poster_path: "/harry2.jpg", + vote_average: 7.6, + }, + { + id: 103, + title: "해리 포터와 아즈카반의 죄수", + poster_path: "/harry3.jpg", + vote_average: 8.0, + }, + ], + total_pages: 1, + total_results: 3, + }, + }).as("searchMovies"); + cy.visit("http://localhost:5173"); cy.get(".search-input").type("해리포터"); cy.get(".search-button").click(); }); - it("검색 버튼을 클릭하면 API가 호출된다", () => { - cy.wait("@searchMovies") - .its("response.body.results") - .should("be.an", "array"); + it("검색 버튼을 클릭하면 검색 결과가 렌더된다.", () => { + cy.wait("@searchMovies"); + cy.get(".thumbnail-item").should("have.length", 3); + }); + + it("검색한 입력값이 제목에 보인다.", () => { + cy.wait("@searchMovies"); + cy.get(".thumbnail-title").should("contain", "해리포터"); }); - it("리스트 안의 요소를 확인한다.", () => { - cy.wait("@searchMovies") - .its("response.body.results") - .then((results) => { - cy.get(".thumbnail").each(($el, index) => { - cy.wrap($el) - .should("have.attr", "src") - .and("include", results[index].poster_path); - }); - - cy.get(".item-rate").each(($el, index) => { - cy.wrap($el).should("contain", results[index].vote_average); - }); - - cy.get(".item-title").each(($el, index) => { - cy.wrap($el).should("contain", results[index].title); - }); - }); + it("영화 카드 클릭 시 모달이 열린다.", () => { + cy.wait("@searchMovies"); + + cy.get(".item").first().click(); + cy.get(".modal").should("be.visible"); }); }); describe("검색 데이터 없을 때 테스트", () => { it("검색 데이터가 없으면 '검색 결과가 없습니다.'라는 문구를 띄운다.", () => { - cy.intercept("GET", "**/search/movie*").as("searchMovies"); + cy.intercept("GET", "**/search/movie*", { + statusCode: 200, + body: { + page: 1, + results: [], + total_pages: 0, + total_results: 0, + }, + }).as("emptySearchMovies"); + cy.visit("http://localhost:5173"); cy.get(".search-input").type("asdfdas"); @@ -47,16 +75,3 @@ describe("검색 데이터 없을 때 테스트", () => { .and("contain", "검색 결과가 없습니다."); }); }); - -describe("검색 데이터가 전부 출력되었을 때 더보기 버튼 사라지는 테스트", () => { - beforeEach("검색어를 입력하고 검색 버튼을 클릭한다.", () => { - cy.visit("http://localhost:5173"); - - cy.get(".search-input").type("해리포터"); - cy.get(".search-button").click(); - }); - - it("검색 데이터가 마지막 데이터면 더보기 버튼이 사라진다.", () => { - cy.get(".thumbnail-add-button").should("not.be.visible"); - }); -}); diff --git a/index.html b/index.html index bd566ea99..ad74dfa77 100644 --- a/index.html +++ b/index.html @@ -51,7 +51,8 @@

지금 인기 있는 영화

- + +
@@ -60,6 +61,15 @@

+ + diff --git a/src/api/getDetail.ts b/src/api/getDetail.ts new file mode 100644 index 000000000..3719cc99a --- /dev/null +++ b/src/api/getDetail.ts @@ -0,0 +1,24 @@ +import { OPTIONS } from "../constants/api"; +import { ResponseError } from "../error/responseError"; + +export async function getDetail(id: number): Promise { + try { + const response: Response = await fetch( + `https://api.themoviedb.org/3/movie/${id}?language=ko-KR®ion=KR`, + OPTIONS, + ); + + if (!response.ok) { + throw new ResponseError("[Error]: API 에러", "HTTP", response.status); + } + + const data: MovieItem = await response.json(); + return data; + } catch (error) { + if (error instanceof ResponseError) { + throw error; + } + + throw new ResponseError("[Error]: 네트워크 에러", "NETWORK"); + } +} diff --git a/src/api/getRate.ts b/src/api/getRate.ts new file mode 100644 index 000000000..ea21e41bb --- /dev/null +++ b/src/api/getRate.ts @@ -0,0 +1,4 @@ +export async function getRate(movieId: string): Promise { + const rate = localStorage.getItem(movieId); + return rate; +} diff --git a/src/api/setRate.ts b/src/api/setRate.ts new file mode 100644 index 000000000..9d077b19c --- /dev/null +++ b/src/api/setRate.ts @@ -0,0 +1,3 @@ +export async function setRate(movieId: string, rate: string): Promise { + localStorage.setItem(movieId, rate); +} diff --git a/src/constants/rate.ts b/src/constants/rate.ts new file mode 100644 index 000000000..5ce9a9e60 --- /dev/null +++ b/src/constants/rate.ts @@ -0,0 +1,8 @@ +export const USER_RATE: Record = { + 0: "별점을 입력해주세요", + 1: "최악이에요", + 2: "별로에요", + 3: "보통이에요", + 4: "재미있어요", + 5: "명작이에요", +}; diff --git a/src/controller/handleResponseError.ts b/src/controller/handleResponseError.ts new file mode 100644 index 000000000..4353aaf88 --- /dev/null +++ b/src/controller/handleResponseError.ts @@ -0,0 +1,23 @@ +import { ERROR_MESSAGE } from "../constants/error"; +import { ResponseError } from "../error/responseError"; + +export function handleResponseError( + error: unknown, + movieListView: MovieListViewType, + infiniteScrollView: InfiniteScrollViewType, +): void { + if (error instanceof ResponseError) { + if (error.type === "HTTP") { + movieListView.errorRender(ERROR_MESSAGE.HTTP); + infiniteScrollView.stop(); + return; + } + if (error.type === "NETWORK") { + movieListView.errorRender(ERROR_MESSAGE.NETWORK); + infiniteScrollView.stop(); + return; + } + } + movieListView.errorRender(ERROR_MESSAGE.DEFAULT); + infiniteScrollView.stop(); +} diff --git a/src/controller/mainController.ts b/src/controller/mainController.ts index ea8d9efc1..692cd2d07 100644 --- a/src/controller/mainController.ts +++ b/src/controller/mainController.ts @@ -4,26 +4,36 @@ import { } from "./moreButtonController"; import { popularController } from "./popularController"; import { searchController } from "./searchController"; -import { AddButtonView } from "../view/addButtonView"; import { MovieBannerView } from "../view/movieBannerView"; import { MovieListView } from "../view/movieListView"; import { SearchView } from "../view/searchView"; import { AppState } from "../state/appState"; +import { ModalView } from "../view/modalView"; +import { modalController } from "./modalController"; +import { rateController } from "./rateController"; +import { InfiniteScrollView } from "../view/infiniteScrollView"; export class MainController { #movieListView; #movieBannerView; #searchView; - #addButtonView; + #infiniteScrollView; + #modalView; #appState; constructor() { - this.#movieListView = new MovieListView(); - this.#movieBannerView = new MovieBannerView(); + this.#movieListView = new MovieListView((id: string) => + this.#handleMovieItem(id), + ); + this.#movieBannerView = new MovieBannerView((id: string) => + this.#handleMovieItem(id), + ); this.#searchView = new SearchView(() => this.#handleSearch()); - this.#addButtonView = new AddButtonView(() => this.#handleAddButton()); - + this.#modalView = new ModalView((id: string) => this.#handleRate(id)); + this.#infiniteScrollView = new InfiniteScrollView(() => + this.#getObserver(), + ); this.#appState = new AppState(); } @@ -37,16 +47,19 @@ export class MainController { this.#appState.getPage(), this.#movieListView, this.#movieBannerView, - this.#addButtonView, + this.#infiniteScrollView, ); } async #handleSearch() { this.#appState.resetSearch(); - this.#addButtonView.show(); this.#appState.setValue(this.#searchView.getValue()); - searchController(this.#appState, this.#movieListView, this.#addButtonView); + await searchController( + this.#appState, + this.#movieListView, + this.#infiniteScrollView, + ); this.#appState.setIsSearch(true); this.#movieBannerView.hide(); @@ -56,19 +69,47 @@ export class MainController { ); } + #getObserver() { + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + + if (entry.isIntersecting) { + this.#handleAddButton(); + } + }, + { + root: null, + rootMargin: "100px", + threshold: 0, + }, + ); + + return observer; + } + async #handleAddButton() { if (this.#appState.getIsSearch()) { moreSearchController( this.#appState, this.#movieListView, - this.#addButtonView, + this.#infiniteScrollView, ); } else { morePopularController( this.#appState, this.#movieListView, - this.#addButtonView, + this.#infiniteScrollView, ); } } + + async #handleMovieItem(id: string) { + this.#modalView.open(); + modalController(id, this.#modalView, this.#appState); + } + + async #handleRate(rate: string) { + rateController(rate, this.#modalView, this.#appState); + } } diff --git a/src/controller/modalController.ts b/src/controller/modalController.ts new file mode 100644 index 000000000..42fd35026 --- /dev/null +++ b/src/controller/modalController.ts @@ -0,0 +1,35 @@ +import { getDetail } from "../api/getDetail"; +import { getRate } from "../api/getRate"; +import { ERROR_MESSAGE } from "../constants/error"; +import { ResponseError } from "../error/responseError"; + +export async function modalController( + id: string, + modalView: ModalViewType, + state: AppStateType, +) { + try { + modalView.spinnerRender(); + const movieDetail = await getDetail(Number(id)); + state.setCurrentMovie(id); + + const myRate = await getRate(id); + if (myRate === null) { + modalView.render(movieDetail, 0); + return; + } + modalView.render(movieDetail, Number(myRate)); + } catch (error) { + if (error instanceof ResponseError) { + if (error.type === "HTTP") { + modalView.errorRender(ERROR_MESSAGE.HTTP); + return; + } + if (error.type === "NETWORK") { + modalView.errorRender(ERROR_MESSAGE.NETWORK); + return; + } + } + modalView.errorRender(ERROR_MESSAGE.DEFAULT); + } +} diff --git a/src/controller/moreButtonController.ts b/src/controller/moreButtonController.ts index 7900e81ae..55a4fbc30 100644 --- a/src/controller/moreButtonController.ts +++ b/src/controller/moreButtonController.ts @@ -1,35 +1,24 @@ import { getMovies } from "../api/getMovies"; import { searchMovies } from "../api/searchMovies"; import { SKELETON_NUMBER } from "../constants/constant"; -import { ERROR_MESSAGE } from "../constants/error"; -import { ResponseError } from "../error/responseError"; import { isLastPage } from "../utils/isLastPage"; +import { handleResponseError } from "./handleResponseError"; export async function morePopularController( state: AppStateType, movieListView: MovieListViewType, - addButtonView: AddButtonViewType, + infiniteScrollView: InfiniteScrollViewType, ) { try { movieListView.skeletonRender(SKELETON_NUMBER); const popularMoviesData: movieResponse = await getMovies( state.getNextPage(), ); - if (isLastPage(popularMoviesData)) addButtonView.hide(); + if (isLastPage(popularMoviesData)) infiniteScrollView.stop(); state.increasePage(); movieListView.render(popularMoviesData.results); } catch (error) { - if (error instanceof ResponseError) { - if (error.type === "HTTP") { - movieListView.errorRender(ERROR_MESSAGE.HTTP); - return; - } - if (error.type === "NETWORK") { - movieListView.errorRender(ERROR_MESSAGE.NETWORK); - return; - } - movieListView.errorRender(ERROR_MESSAGE.DEFAULT); - } + handleResponseError(error, movieListView, infiniteScrollView); } finally { movieListView.skeletonRemover(); } @@ -38,7 +27,7 @@ export async function morePopularController( export async function moreSearchController( state: AppStateType, movieListView: MovieListViewType, - addButtonView: AddButtonViewType, + infiniteScrollView: InfiniteScrollViewType, ) { try { movieListView.skeletonRender(SKELETON_NUMBER); @@ -46,24 +35,11 @@ export async function moreSearchController( state.getSearchValue(), state.getNextPage(), ); - if (isLastPage(searchMoviesData)) addButtonView.hide(); + if (isLastPage(searchMoviesData)) infiniteScrollView.stop(); state.increasePage(); movieListView.render(searchMoviesData.results); } catch (error) { - if (error instanceof ResponseError) { - if (error.type === "HTTP") { - addButtonView.hide(); - movieListView.errorRender(ERROR_MESSAGE.HTTP); - return; - } - if (error.type === "NETWORK") { - addButtonView.hide(); - movieListView.errorRender(ERROR_MESSAGE.NETWORK); - return; - } - addButtonView.hide(); - movieListView.errorRender(ERROR_MESSAGE.DEFAULT); - } + handleResponseError(error, movieListView, infiniteScrollView); } finally { movieListView.skeletonRemover(); } diff --git a/src/controller/popularController.ts b/src/controller/popularController.ts index 213364d54..c0b965cc8 100644 --- a/src/controller/popularController.ts +++ b/src/controller/popularController.ts @@ -1,40 +1,27 @@ import { getMovies } from "../api/getMovies"; import { SKELETON_NUMBER } from "../constants/constant"; -import { ERROR_MESSAGE } from "../constants/error"; -import { ResponseError } from "../error/responseError"; +import { handleResponseError } from "./handleResponseError"; export async function popularController( page: number, movieListView: MovieListViewType, movieBannerView: MovieBannerViewType, - addButtonView: AddButtonViewType, + infiniteScrollView: InfiniteScrollViewType, ) { try { movieListView.reset(); + infiniteScrollView.start(); movieListView.skeletonRender(SKELETON_NUMBER); const popularMovies: movieResponse = await getMovies(page); if (popularMovies.results.length === 0) { movieListView.emptyRender(); - addButtonView.hide(); + infiniteScrollView.stop(); return; } movieBannerView.render(popularMovies.results[0]); movieListView.render(popularMovies.results); } catch (error) { - if (error instanceof ResponseError) { - if (error.type === "HTTP") { - movieListView.errorRender(ERROR_MESSAGE.HTTP); - addButtonView.hide(); - return; - } - if (error.type === "NETWORK") { - movieListView.errorRender(ERROR_MESSAGE.NETWORK); - addButtonView.hide(); - return; - } - movieListView.errorRender(ERROR_MESSAGE.DEFAULT); - addButtonView.hide(); - } + handleResponseError(error, movieListView, infiniteScrollView); } finally { movieListView.skeletonRemover(); } diff --git a/src/controller/rateController.ts b/src/controller/rateController.ts new file mode 100644 index 000000000..ab61549a9 --- /dev/null +++ b/src/controller/rateController.ts @@ -0,0 +1,10 @@ +import { setRate } from "../api/setRate"; + +export async function rateController( + rate: string, + modalView: ModalViewType, + state: AppStateType, +) { + await setRate(state.getCurrentMovie(), rate); + modalView.renderRate(Number(rate)); +} diff --git a/src/controller/searchController.ts b/src/controller/searchController.ts index 9f88102ae..0ee24f9a8 100644 --- a/src/controller/searchController.ts +++ b/src/controller/searchController.ts @@ -1,16 +1,16 @@ import { searchMovies } from "../api/searchMovies"; import { SKELETON_NUMBER } from "../constants/constant"; -import { ERROR_MESSAGE } from "../constants/error"; -import { ResponseError } from "../error/responseError"; import { isLastPage } from "../utils/isLastPage"; +import { handleResponseError } from "./handleResponseError"; export async function searchController( state: AppStateType, movieListView: MovieListViewType, - addButtonView: AddButtonViewType, + infiniteScrollView: InfiniteScrollViewType, ) { try { movieListView.reset(); + infiniteScrollView.start(); movieListView.skeletonRender(SKELETON_NUMBER); const searchMoviesResult: movieResponse = await searchMovies( state.getSearchValue(), @@ -18,28 +18,15 @@ export async function searchController( ); if (searchMoviesResult.total_results === 0) { movieListView.emptyRender(); - addButtonView.hide(); + infiniteScrollView.stop(); return; } if (isLastPage(searchMoviesResult)) { - addButtonView.hide(); + infiniteScrollView.stop(); } movieListView.render(searchMoviesResult.results); } catch (error) { - if (error instanceof ResponseError) { - if (error.type === "HTTP") { - addButtonView.hide(); - movieListView.errorRender(ERROR_MESSAGE.HTTP); - return; - } - if (error.type === "NETWORK") { - addButtonView.hide(); - movieListView.errorRender(ERROR_MESSAGE.NETWORK); - return; - } - addButtonView.hide(); - movieListView.errorRender(ERROR_MESSAGE.DEFAULT); - } + handleResponseError(error, movieListView, infiniteScrollView); } finally { movieListView.skeletonRemover(); } diff --git a/src/state/appState.ts b/src/state/appState.ts index 23b209738..9c248f4b5 100644 --- a/src/state/appState.ts +++ b/src/state/appState.ts @@ -1,12 +1,14 @@ export class AppState { - #page; - #isSearch; - #searchValue; + #page: number; + #isSearch: boolean; + #searchValue: string; + #currentMovie: string; constructor() { this.#page = 1; this.#isSearch = false; this.#searchValue = ""; + this.#currentMovie = ""; } reset() { @@ -37,6 +39,10 @@ export class AppState { return this.#isSearch; } + getCurrentMovie() { + return this.#currentMovie; + } + increasePage() { this.#page++; } @@ -48,4 +54,8 @@ export class AppState { setIsSearch(nextIsSearch: boolean) { this.#isSearch = nextIsSearch; } + + setCurrentMovie(movieId: string) { + this.#currentMovie = movieId; + } } diff --git a/src/styles/index.css b/src/styles/index.css index 54376881f..d1ed87e69 100644 --- a/src/styles/index.css +++ b/src/styles/index.css @@ -4,4 +4,4 @@ @import "./reset.css"; @import "./tab.css"; @import "./thumbnail.css"; -@import "./skeleton.css"; +@import "./loading.css"; diff --git a/src/styles/loading.css b/src/styles/loading.css new file mode 100644 index 000000000..07664e113 --- /dev/null +++ b/src/styles/loading.css @@ -0,0 +1,91 @@ +.skeleton-card { + list-style: none; +} + +.skeleton-box { + position: relative; + overflow: hidden; + background-color: #86888d; +} + +.skeleton-box::after { + content: ""; + position: absolute; + top: 0; + left: -150%; + width: 150%; + height: 100%; + transform: skewX(-20deg); + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.9), + transparent + ); + animation: skeleton-loading 1.2s infinite; +} + +@keyframes skeleton-loading { + 100% { + left: 150%; + } +} + +.skeleton-card .thumbnail { + width: 200px; + height: 300px; + border-radius: 8px; +} + +.skeleton-card .item-desc { + margin-top: 8px; +} + +.skeleton-card .rate { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} + +.skeleton-star { + width: 16px; + height: 16px; + border-radius: 4px; +} + +.skeleton-score { + width: 40px; + height: 16px; + border-radius: 4px; +} + +.skeleton-title { + display: block; + width: 140px; + height: 20px; + border-radius: 4px; +} + +.modal-loading { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.spinner { + width: 48px; + height: 48px; + border: 4px solid rgba(255, 255, 255, 0.2); + border-top: 4px solid white; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/styles/main.css b/src/styles/main.css index c4dd6b93f..01a08f075 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -62,9 +62,10 @@ button.primary { } .header-bar { - position: absolute; + position: fixed; z-index: 10; - width: 1280px; + max-width: 1280px; + width: 100%; justify-self: center; padding: 40px 0; @@ -143,9 +144,6 @@ button.primary { margin: 0 auto; } -.top-rated-movie { -} - .top-rated-movie > *:not(:last-child) { margin-bottom: 8px; } @@ -162,7 +160,7 @@ h1.logo { .rate > img { position: relative; - top: 2px; + top: -2px; } span.rate-value { @@ -189,3 +187,32 @@ footer.footer { footer.footer p:not(:last-child) { margin-bottom: 8px; } + +@media (max-width: 1279px) { + .header-bar { + display: flex; + flex-direction: column; + gap: 24px; + } + + .background-container { + padding: 80px 48px; + align-items: end; + } +} + +@media (max-width: 670px) { + .header-bar { + max-width: 300px; + width: 100%; + } + + .background-container { + height: 400px; + } + + .title { + font-size: 24px; + font-weight: 600; + } +} diff --git a/src/styles/modal.css b/src/styles/modal.css index 240a7a37c..b5d62eba1 100644 --- a/src/styles/modal.css +++ b/src/styles/modal.css @@ -11,6 +11,7 @@ body.modal-open { left: 0; width: 100%; height: 100%; + overflow: hidden; background-color: rgba(0, 0, 0, 0.5); /* 반투명 배경을 위해 설정 */ backdrop-filter: blur(10px); /* 블러 효과 적용 */ display: flex; @@ -37,6 +38,7 @@ body.modal-open { z-index: 2; position: relative; width: 1000px; + height: 588px; } .close-modal { @@ -50,14 +52,17 @@ body.modal-open { color: white; font-size: 20px; cursor: pointer; + display: flex; } .modal-container { + height: 100%; display: flex; } .modal-image img { width: 380px; + height: 545px; border-radius: 16px; } @@ -66,23 +71,188 @@ body.modal-open { padding: 8px; margin-left: 16px; line-height: 1.6rem; -} -.modal-description .rate > img { - position: relative; - top: 5px; -} + display: flex; + flex-direction: column; + gap: 16px; -.modal-description > *:not(:last-child) { - margin-bottom: 8px; + height: 545px; } .modal-description h2 { - font-size: 2rem; + font-size: 32px; + font-weight: 700; margin: 0 0 8px; } +.modal-description h3 { + font-size: 24px; + font-weight: 600; +} + +.modal-title-section { + display: flex; + flex-direction: column; + gap: 16px; +} + +.rate-section { + display: flex; + gap: 16px; + align-items: center; +} + +.rate { + display: flex; + gap: 8px; + align-items: center; +} + +.rate img { + height: 100%; +} + +.rate p { + font-size: 24px; + font-weight: 600; + color: var(--color-yellow); +} + .detail { max-height: 430px; overflow-y: auto; } + +.modal-myrate-section { + padding: 24px 0; + border-top: 1px solid var(--color-bluegray-30); + border-bottom: 1px solid var(--color-bluegray-30); + display: flex; + flex-direction: column; + gap: 24px; +} + +.myrate-section { + display: flex; + gap: 16px; + align-items: center; + font-size: 24px; + font-weight: 600; +} + +.myrate-stars { + width: 170px; + box-sizing: content-box; + display: flex; + gap: 2; +} + +.myrate-stars img { + width: 32px; + height: 32px; + position: relative; + top: -2px; + cursor: pointer; +} + +.myrate-score { + color: var(--color-bluegray-10); +} + +.modal-detail-section { + flex: 1; + display: flex; + flex-direction: column; + gap: 16px; + overflow: hidden; +} + +.modal-detail-section .detail { + flex: 1; + overflow-y: scroll; +} + +.modal-error { + width: 100%; + height: 545px; + display: flex; + flex-direction: column; + gap: 32px; + justify-content: center; + align-items: center; +} + +.modal-error p { + font-size: 24px; + font-weight: 600; +} + +@media (max-width: 1279px) { + .modal-background { + align-items: end; + } + + .modal { + position: relative; + width: 100%; + height: 85%; + border-radius: 0; + padding: 40px 20px; + overflow: hidden; + } + + .close-modal { + position: fixed; + top: auto; + } + + .modal-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + overflow-y: scroll; + overflow-x: hidden; + } + + .modal-image img { + width: 200px; + height: 300px; + border-radius: 16px; + } + + .modal-title-section { + align-items: center; + gap: 8px; + } + + .modal-detail-section { + overflow: auto; + } + + .modal-detail-section .detail { + flex: 1; + overflow-y: auto; + } +} + +@media (max-width: 670px) { + .modal { + width: 100%; + height: 70%; + border-radius: 0; + padding: 40px 20px; + } + + .modal-myrate-section { + align-items: center; + } + + .myrate-section { + flex-direction: column; + } + + .modal-image { + display: none; + } +} diff --git a/src/styles/thumbnail.css b/src/styles/thumbnail.css index 2f802a178..dda8fd615 100644 --- a/src/styles/thumbnail.css +++ b/src/styles/thumbnail.css @@ -78,3 +78,31 @@ p.rate > span { font-size: 36px; font-weight: 600; } + +@media (max-width: 1279px) { + .thumbnail-container { + max-width: 800px; + width: 100%; + box-sizing: border-box; + padding: 0 32px; + } + + .thumbnail-list { + width: 100%; + grid-template-columns: repeat(3, 200px); + justify-content: space-between; + gap: 70px 0; + } +} + +@media (max-width: 670px) { + .thumbnail-container { + width: 100%; + align-items: center; + } + + .thumbnail-list { + width: auto; + grid-template-columns: repeat(1, 200px); + } +} diff --git a/src/utils/formatMovieMeta.ts b/src/utils/formatMovieMeta.ts new file mode 100644 index 000000000..7a4f789cb --- /dev/null +++ b/src/utils/formatMovieMeta.ts @@ -0,0 +1,9 @@ +export function formatMovieMeta(date: string, genres: Genre[] = []): string { + const year = date.split("-")[0] || ""; + const genreNames = genres.map((genre) => genre.name).join(", "); + + if (!year) return genreNames; + if (!genreNames) return year; + + return `${year} · ${genreNames}`; +} diff --git a/src/view/infiniteScrollView.ts b/src/view/infiniteScrollView.ts new file mode 100644 index 000000000..c21fb3629 --- /dev/null +++ b/src/view/infiniteScrollView.ts @@ -0,0 +1,19 @@ +export class InfiniteScrollView { + #sentinel; + #observer; + + constructor(getObserver: () => IntersectionObserver) { + this.#sentinel = document.querySelector("#sentinel"); + this.#observer = getObserver(); + } + + start() { + if (!this.#sentinel) return; + + this.#observer.observe(this.#sentinel); + } + + stop() { + this.#observer.disconnect(); + } +} diff --git a/src/view/modalView.ts b/src/view/modalView.ts new file mode 100644 index 000000000..517d03a2e --- /dev/null +++ b/src/view/modalView.ts @@ -0,0 +1,166 @@ +import { USER_RATE } from "../constants/rate"; +import { formatMovieMeta } from "../utils/formatMovieMeta"; +import emptyIcon from "../asset/images/empty_icon.png"; +import filledStar from "../asset/images/star_filled.png"; +import emptyStar from "../asset/images/star_empty.png"; + +export class ModalView { + #modalSection; + #closeButton; + #detailSection; + #handle; + + constructor(handle: (id: string) => {}) { + this.#modalSection = document.querySelector("#modalBackground"); + this.#closeButton = document.querySelector("#closeModal"); + this.#detailSection = document.querySelector("#modalContainer"); + this.#handle = handle; + this.#modalBinding(); + } + + #modalBinding() { + this.#closeButton?.addEventListener("click", () => this.close()); + this.#modalSection?.addEventListener("click", (event) => + this.#backgroundClose(event), + ); + window.addEventListener("keydown", (event) => this.#escFunction(event)); + } + + #escFunction(event: KeyboardEvent) { + if (event.key === "Escape") { + this.close(); + } + } + + #backgroundClose(event: Event) { + if (event.target === this.#modalSection) { + this.close(); + } + } + + render(item: MovieItem, rateCount: number) { + if (!this.#detailSection) return; + + this.#detailSection.innerHTML = ` + + + `; + + this.#starsBinding(); + } + + spinnerRender() { + if (!this.#detailSection) return; + + this.#detailSection.innerHTML = ` + + `; + } + + errorRender(message: string) { + const emptyList = /*html*/ ` + + `; + + if (!this.#detailSection) return; + this.#detailSection.innerHTML = emptyList; + } + + renderRate(rate: number) { + const myComment = this.#detailSection?.querySelector(".myrate-comment"); + + if (!myComment) return; + + myComment.innerHTML = ` + ${USER_RATE[rate]} (${rate * 2}/10) + `; + + const myStars = this.#detailSection?.querySelector(".myrate-stars"); + + if (!myStars) return; + + myStars.innerHTML = ` + ${this.#renderStars(rate)} + `; + } + + #renderStars(rate: number) { + return Array.from({ length: 5 }, (_, index) => { + const starIcon = rate <= index ? emptyStar : filledStar; + + return ` + star + `; + }).join(""); + } + + #starsBinding() { + const starSection = this.#detailSection?.querySelector(".myrate-stars"); + + starSection?.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + + const item = target.closest("img"); + if (!item?.id) return; + + this.#handle(item.id); + }); + } + + open() { + if (!this.#modalSection) return; + + document.body.style.overflow = "hidden"; + this.#modalSection.classList.add("active"); + } + + close() { + if (!this.#modalSection) return; + + document.body.style.overflow = "auto"; + this.#modalSection.classList.remove("active"); + } +} diff --git a/src/view/movieBannerView.ts b/src/view/movieBannerView.ts index 6fc8dfc6e..72c5c7629 100644 --- a/src/view/movieBannerView.ts +++ b/src/view/movieBannerView.ts @@ -1,15 +1,20 @@ const BANNER_IMAGE_URL = "https://image.tmdb.org/t/p/w1920_and_h800_multi_faces"; +import emptyStar from "../asset/images/star_empty.png"; + export class MovieBannerView { #section; #headerBar; + #handle; - constructor() { + constructor(handle: (id: string) => void) { this.#section = document.querySelector( ".background-container", ); this.#headerBar = document.querySelector("#header-bar"); + this.#handle = handle; + this.#logoBinding(); } render(bannerMovie: Movies) { @@ -21,6 +26,7 @@ export class MovieBannerView {
+ ${bannerMovie.vote_average}
${bannerMovie.title}
@@ -28,6 +34,24 @@ export class MovieBannerView {
`; + + this.#binding(bannerMovie.id); + } + + #binding(id: number) { + const detailButton = this.#section?.querySelector(".detail"); + if (!detailButton) return; + detailButton.addEventListener("click", () => { + this.#handle(id.toString()); + }); + } + + #logoBinding() { + if (!this.#headerBar) return; + const logo = this.#headerBar.querySelector(".logo"); + logo?.addEventListener("click", () => { + window.location.reload(); + }); } hide() { diff --git a/src/view/movieListView.ts b/src/view/movieListView.ts index 80fcc7fe4..4a10336e2 100644 --- a/src/view/movieListView.ts +++ b/src/view/movieListView.ts @@ -1,4 +1,5 @@ import { ERROR_MESSAGE } from "../constants/error"; + import emptyStar from "../asset/images/star_empty.png"; import emptyIcon from "../asset/images/empty_icon.png"; import noImage from "../asset/images/no-image.png"; @@ -6,10 +7,13 @@ import noImage from "../asset/images/no-image.png"; export class MovieListView { #titleSection; #listSection; + #handle; - constructor() { + constructor(handle: (id: string) => void) { this.#listSection = document.querySelector(".thumbnail-list"); this.#titleSection = document.querySelector("#thumbnail-title"); + this.#handle = handle; + this.#binding(); } setTitle(title: string) { @@ -22,7 +26,7 @@ export class MovieListView { movieList?.forEach((item) => { const list = /*html*/ ` -
  • +
  • ${item.vote_average} + />${item.vote_average.toFixed(1)}

    ${item.title}
    @@ -48,6 +52,20 @@ export class MovieListView { }); } + #binding() { + if (!this.#listSection) return; + + this.#listSection.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + + const item = target.closest("li"); + if (!item?.id) return; + + this.#handle(item.id); + }); + } + emptyRender() { this.reset(); const emptyList = /*html*/ ` diff --git a/types/api.d.ts b/types/api.d.ts index b6e6909f3..312a7264b 100644 --- a/types/api.d.ts +++ b/types/api.d.ts @@ -11,3 +11,18 @@ interface Movies { poster_path: string; vote_average: number; } + +interface MovieItem { + id: number; + title: string; + poster_path: string; + vote_average: number; + release_date: string; + genres: Genre[]; + overview: string; +} + +interface Genre { + id: number; + name: string; +} diff --git a/types/state.d.ts b/types/state.d.ts index 6cadd18be..9686ba8f8 100644 --- a/types/state.d.ts +++ b/types/state.d.ts @@ -5,7 +5,9 @@ interface AppStateType { getNextPage(): number; getSearchValue(): string; getIsSearch(): boolean; + getCurrentMovie(): string; increasePage(): void; setValue(string): void; setIsSearch(boolean): void; + setCurrentMovie(string): void; } diff --git a/types/view.d.ts b/types/view.d.ts index 7440abd47..867786729 100644 --- a/types/view.d.ts +++ b/types/view.d.ts @@ -17,3 +17,17 @@ interface AddButtonViewType { show(): void; hide(): void; } + +interface ModalViewType { + open(): void; + close(): void; + render(item: MovieItem, count: number): void; + renderRate(rate: number); + spinnerRender(): void; + errorRender(message: string): void; +} + +interface InfiniteScrollViewType { + start(): void; + stop(): void; +}