diff --git a/cypress/e2e/movie-e2e.cy.ts b/cypress/e2e/movie-e2e.cy.ts index 64302a29f..92796d5ef 100644 --- a/cypress/e2e/movie-e2e.cy.ts +++ b/cypress/e2e/movie-e2e.cy.ts @@ -188,3 +188,23 @@ describe("네트워크 에러 확인", () => { }); }); }); + +describe("영화 상세 정보(모달창)", () => { + beforeEach(() => { + cy.visit("http://localhost:8081/"); + cy.viewport(1920, 1080); + }); + + it("영화 목록 중 하나를 선택하여 클릭하면 모달창이 화면에 뜬다.", () => { + cy.intercept( + { + method: "GET", + url: /^https:\/\/api.themoviedb.org\/3\/movie\/*/, + }, + { fixture: "detail.json" } + ); + + cy.get("movie-item").first().click(); + cy.get(".modal-body h3").should("contain", "첫번째 테스트"); + }); +}); diff --git a/cypress/fixtures/detail.json b/cypress/fixtures/detail.json new file mode 100644 index 000000000..2c7dbafa8 --- /dev/null +++ b/cypress/fixtures/detail.json @@ -0,0 +1,56 @@ +{ + "adult": false, + "backdrop_path": "/a2tys4sD7xzVaogPntGsT1ypVoT.jpg", + "belongs_to_collection": null, + "budget": 35000000, + "genres": [ + { "id": 53, "name": "스릴러" }, + { "id": 35, "name": "코미디" }, + { "id": 27, "name": "공포" }, + { "id": 80, "name": "범죄" } + ], + "homepage": "", + "id": 804150, + "imdb_id": "tt14209916", + "original_language": "en", + "original_title": "Cocaine Bear", + "overview": "1985년 마약 밀매업자의 비행기 추락 사고로 인해 비행기에 실려있던 코카인이 조지아주 채터후치 국유림에 떨어지고 코카인을 손에 넣기 위한 마약상들과 관광객들이 코카인을 먹은 흑곰에 의해 처참하게 죽임을 당한다는 내용의 실화를 바탕으로 한 영화", + "popularity": 4435.857, + "poster_path": "/gOnmaxHo0412UVr1QM5Nekv1xPi.jpg", + "production_companies": [ + { + "id": 33, + "logo_path": "/8lvHyhjr8oUKOOy2dKXoALWKdp0.png", + "name": "Universal Pictures", + "origin_country": "US" + }, + { + "id": 12365, + "logo_path": null, + "name": "Brownstone Productions", + "origin_country": "US" + }, + { + "id": 77973, + "logo_path": null, + "name": "Lord Miller Productions", + "origin_country": "US" + } + ], + "production_countries": [ + { "iso_3166_1": "IE", "name": "Ireland" }, + { "iso_3166_1": "US", "name": "United States of America" } + ], + "release_date": "2023-02-22", + "revenue": 70968453, + "runtime": 95, + "spoken_languages": [ + { "english_name": "English", "iso_639_1": "en", "name": "English" } + ], + "status": "Released", + "tagline": "", + "title": "첫번째 테스트", + "video": false, + "vote_average": 6.554, + "vote_count": 493 +} diff --git a/index.html b/index.html index 8a852d2e4..7f40e912d 100644 --- a/index.html +++ b/index.html @@ -8,5 +8,6 @@ + diff --git a/src/components/AppComponent.js b/src/components/AppComponent.js index 56b094453..cd2034d30 100644 --- a/src/components/AppComponent.js +++ b/src/components/AppComponent.js @@ -3,10 +3,21 @@ import HeaderComponent from "./AppHeaderComponent"; import MovieListComponent from "./movie/MovieListComponent"; import MoreButtonComponent from "./element/MoreButtonComponent"; import TitleComponent from "./element/TitleComponent"; +import ModalComponent from "./ModalComponent"; + +import UpScrollButtonComponent from "./element/UpScrollButtonComponent"; import transformMovieItemsType from "../util/MovieList"; -import { ACTION, SEARCH_WARNING, TITLE } from "../constants/constants"; +import { + ACTION, + ERROR_MESSAGE, + REQUEST_URL, + SCROLL_INVOKE_GAP, + SEARCH_WARNING, + TITLE, +} from "../constants/constants"; import { getRequest, transData } from "../api/handler"; import { urlByActionType } from "../api/url"; +import { API_KEY } from "../constants/key"; export default class AppComponent extends CustomComponent { #nextPage = 1; @@ -14,6 +25,8 @@ export default class AppComponent extends CustomComponent { #$movieList; #$movieListTitle; #$searchInput; + #scrollThrottleId; + #actionType; render() { super.render(); @@ -21,6 +34,7 @@ export default class AppComponent extends CustomComponent { this.#$movieList = this.querySelector("movie-list"); this.#$movieListTitle = this.querySelector("movie-list-title"); this.#$searchInput = this.querySelector("input"); + this.#actionType = ACTION.POPULAR; this.popularListInit(); this.getMovieData(ACTION.POPULAR); @@ -49,13 +63,17 @@ export default class AppComponent extends CustomComponent { } changeButtonDisplayByPage() { - if (this.#totalPage <= this.#nextPage) { + if (this.isEndOfPage()) { this.querySelector("more-button").classList.add("hide"); return; } this.querySelector("more-button").classList.remove("hide"); } + isEndOfPage() { + return this.#totalPage <= this.#nextPage; + } + searchListInit() { this.#nextPage = 1; @@ -82,7 +100,8 @@ export default class AppComponent extends CustomComponent { switch (e.target.dataset.action) { case ACTION.POPULAR: this.popularListInit(); - this.getMovieData(ACTION.POPULAR); + this.#actionType = ACTION.POPULAR; + this.getMovieData(this.#actionType); this.changeMoreButtonAction(ACTION.MORE_POPULAR); break; case ACTION.SEARCH: @@ -91,16 +110,36 @@ export default class AppComponent extends CustomComponent { return; } this.searchListInit(); - this.getMovieData(ACTION.SEARCH); + this.#actionType = ACTION.SEARCH; + this.getMovieData(this.#actionType); this.changeMoreButtonAction(ACTION.MORE_SEARCH); break; case ACTION.MORE_POPULAR: this.#$movieList.appendNewPage(); - this.getMovieData(ACTION.POPULAR); + this.getMovieData(this.#actionType); break; case ACTION.MORE_SEARCH: this.#$movieList.appendNewPage(); - this.getMovieData(ACTION.SEARCH); + this.getMovieData(this.#actionType); + break; + case ACTION.UP_SCROLL: + window.scroll({ top: 0, behavior: "smooth" }); + break; + case ACTION.DETAIL: + const movieId = e.target.dataset.movieId; + getRequest( + `${REQUEST_URL}/movie/${movieId}?api_key=${API_KEY}&language=ko-KR` + ) + .then((res) => { + const modal = document.querySelector("modal-component"); + modal.setAttribute("data-item", JSON.stringify(res)); + + modal.style.display = "flex"; + document.body.style.overflow = "hidden"; + }) + .catch(() => { + alert(ERROR_MESSAGE); + }); break; } }); @@ -115,10 +154,50 @@ export default class AppComponent extends CustomComponent { } this.searchListInit(); - this.getMovieData(ACTION.SEARCH); + this.#actionType = ACTION.SEARCH; + this.getMovieData(this.#actionType); this.changeMoreButtonAction(ACTION.MORE_SEARCH); } }); + + window.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + const modalComponent = document.querySelector("modal-component"); + + modalComponent.style.display = "none"; + document.body.style.overflow = "visible"; + } + }); + + window.addEventListener("scroll", () => { + if (this.isEndOfPage()) return; + + this.toggleUpScrollButton(); + + if (!this.#scrollThrottleId) { + this.#scrollThrottleId = setTimeout(() => { + if ( + this.getBoundingClientRect().bottom - window.innerHeight < + SCROLL_INVOKE_GAP + ) { + this.#$movieList.appendNewPage(); + this.getMovieData(this.#actionType); + } + this.#scrollThrottleId = null; + }, 1000); + } + }); + } + + toggleUpScrollButton() { + const header = document.querySelector("app-header"); + const upScrollBtn = document.querySelector("up-scroll-button"); + + if (header.getBoundingClientRect().bottom < 0) { + upScrollBtn.classList.remove("hide"); + return; + } + upScrollBtn.classList.add("hide"); } template() { @@ -132,6 +211,7 @@ export default class AppComponent extends CustomComponent { + `; } diff --git a/src/components/ModalComponent.js b/src/components/ModalComponent.js index ea59eddef..2aa71a354 100644 --- a/src/components/ModalComponent.js +++ b/src/components/ModalComponent.js @@ -1,23 +1,128 @@ import CustomComponent from "../abstracts/CustomComponent"; - +import StarFilledImg from "../../templates/star_filled.png"; +import StarEmptyImg from "../../templates/star_empty.png"; +import { OVERVIEW_EMPTY } from "../constants/constants"; export default class ModalComponent extends CustomComponent { + static get observedAttributes() { + return ["data-item"]; + } + + attributeChangedCallback() { + const modalBody = this.querySelector(".modal-body"); + const item = JSON.parse(this.getAttribute("data-item")); + const title = this.querySelector("h3"); + const img = this.querySelector("img"); + const genre = this.querySelector(".movie-detail-header p"); + const desciption = this.querySelector(".movie-detail-description"); + const voteAverage = this.querySelector(".movie-detail-vote-average"); + + title.innerText = item.title; + img.src = `https://image.tmdb.org/t/p/w220_and_h330_face${item.poster_path}`; + img.alt = `별점 ${item.vote_average}`; + genre.innerText = item.genres.map((genre) => genre.name).join(", "); + desciption.innerText = item.overview || OVERVIEW_EMPTY; + voteAverage.innerText = item.vote_average; + modalBody.setAttribute("data-movie-id", item.id); + + const rate = Number(localStorage.getItem(`movie_rate_${item.id}`)); + this.changeRate(rate); + } + + handleEvent() { + this.addEventListener("click", (e) => { + switch (e.target.dataset.action) { + case "modal-close": + this.style.display = "none"; + document.body.style.overflow = "visible"; + break; + } + }); + + const customRateContainer = document.querySelector(".custom-rate"); + customRateContainer.addEventListener("click", (e) => { + if (!e.target.dataset.actionRate) return; + + const modalBody = document.querySelector(".modal-body"); + const rate = Number(e.target.dataset.actionRate); + + this.changeRate(rate); + localStorage.setItem(`movie_rate_${modalBody.dataset.movieId}`, rate); + }); + } + + changeRate(rate) { + const selectRateParagraph = document.querySelector(".select-rate-text"); + const selectRateScore = document.querySelector(".select-rate-score"); + const buttons = document.querySelectorAll(".custom-rate .rate-button"); + + switch (rate) { + case 0: + selectRateScore.innerText = ""; + selectRateParagraph.innerText = "별점을 매겨주세요."; + break; + case 2: + selectRateScore.innerText = "2"; + selectRateParagraph.innerText = "최악이에요"; + break; + case 4: + selectRateScore.innerText = "4"; + selectRateParagraph.innerText = "별로에요"; + break; + case 6: + selectRateScore.innerText = "6"; + selectRateParagraph.innerText = "보통이에요"; + break; + case 8: + selectRateScore.innerText = "8"; + selectRateParagraph.innerText = "재미있어요"; + break; + case 10: + selectRateScore.innerText = "10"; + selectRateParagraph.innerText = "명작이에요"; + break; + default: + break; + } + + for (const button of buttons) { + const img = button.querySelector("img"); + + const imgSrc = + +button.dataset.actionRate > rate ? StarEmptyImg : StarFilledImg; + img.src = imgSrc; + } + } + template() { return ` @@ -75,7 +81,9 @@

지금 인기 있는 영화

alt="앤트맨과 와스프: 퀀텀매니아" />

앤트맨과 와스프: 퀀텀매니아

-

별점6.5

+

+ 별점6.5 +

@@ -89,7 +97,9 @@

지금 인기 있는 영화

alt="앤트맨과 와스프: 퀀텀매니아" />

앤트맨과 와스프: 퀀텀매니아

-

별점6.5

+

+ 별점6.5 +

@@ -103,7 +113,9 @@

지금 인기 있는 영화

alt="앤트맨과 와스프: 퀀텀매니아" />

앤트맨과 와스프: 퀀텀매니아

-

별점6.5

+

+ 별점6.5 +

@@ -117,7 +129,9 @@

지금 인기 있는 영화

alt="앤트맨과 와스프: 퀀텀매니아" />

앤트맨과 와스프: 퀀텀매니아

-

별점6.5

+

+ 별점6.5 +

@@ -131,7 +145,9 @@

지금 인기 있는 영화

alt="앤트맨과 와스프: 퀀텀매니아" />

앤트맨과 와스프: 퀀텀매니아

-

별점6.5

+

+ 별점6.5 +

@@ -145,7 +161,9 @@

지금 인기 있는 영화

alt="앤트맨과 와스프: 퀀텀매니아" />

앤트맨과 와스프: 퀀텀매니아

-

별점6.5

+

+ 별점6.5 +

@@ -159,7 +177,9 @@

지금 인기 있는 영화

alt="앤트맨과 와스프: 퀀텀매니아" />

앤트맨과 와스프: 퀀텀매니아

-

별점6.5

+

+ 별점6.5 +

@@ -173,7 +193,9 @@

지금 인기 있는 영화

alt="앤트맨과 와스프: 퀀텀매니아" />

앤트맨과 와스프: 퀀텀매니아

-

별점6.5

+

+ 별점6.5 +

@@ -187,7 +209,9 @@

지금 인기 있는 영화

alt="앤트맨과 와스프: 퀀텀매니아" />

앤트맨과 와스프: 퀀텀매니아

-

별점6.5

+

+ 별점6.5 +

@@ -201,7 +225,9 @@

지금 인기 있는 영화

alt="앤트맨과 와스프: 퀀텀매니아" />

앤트맨과 와스프: 퀀텀매니아

-

별점6.5

+

+ 별점6.5 +

@@ -215,7 +241,9 @@

지금 인기 있는 영화

alt="앤트맨과 와스프: 퀀텀매니아" />

앤트맨과 와스프: 퀀텀매니아

-

별점6.5

+

+ 별점6.5 +

@@ -229,7 +257,9 @@

지금 인기 있는 영화

alt="앤트맨과 와스프: 퀀텀매니아" />

앤트맨과 와스프: 퀀텀매니아

-

별점6.5

+

+ 별점6.5 +

@@ -243,7 +273,9 @@

지금 인기 있는 영화

alt="앤트맨과 와스프: 퀀텀매니아" />

앤트맨과 와스프: 퀀텀매니아

-

별점6.5

+

+ 별점6.5 +

@@ -257,7 +289,9 @@

지금 인기 있는 영화

alt="앤트맨과 와스프: 퀀텀매니아" />

앤트맨과 와스프: 퀀텀매니아

-

별점6.5

+

+ 별점6.5 +

@@ -271,7 +305,9 @@

지금 인기 있는 영화

alt="앤트맨과 와스프: 퀀텀매니아" />

앤트맨과 와스프: 퀀텀매니아

-

별점6.5

+

+ 별점6.5 +

@@ -285,7 +321,9 @@

지금 인기 있는 영화

alt="앤트맨과 와스프: 퀀텀매니아" />

앤트맨과 와스프: 퀀텀매니아

-

별점6.5

+

+ 별점6.5 +

@@ -299,7 +337,9 @@

지금 인기 있는 영화

alt="앤트맨과 와스프: 퀀텀매니아" />

앤트맨과 와스프: 퀀텀매니아

-

별점6.5

+

+ 별점6.5 +