Skip to content

Commit

Permalink
[2단계 - 상세 정보 & UI/UX 개선하기] 윤생(이윤성) 미션 제출합니다. (#92)
Browse files Browse the repository at this point in the history
* refactor: app component 태그 네이밍 변경

* feat: 무한 스크롤 구현 + 스로틀링

* feat: 업스크롤 버튼 구현

* feat: 모달창 데이터 받아오기 / 토글 기능 구현

* feat: 닫기버튼 기능 추가

* feat: 별점 기능 구현

* feat: 별점 css 및 마크업 구체화

* fix: 마지막 페이지에서 로딩하는 버그 해결

* feat: esc 키로 모달 닫을 수 있게 변경

* feat: 에러 문구 출력

* refactor: console.log 제거

* refactor: 리터럴 상수화

* feat: 모달 반응형 css

* feat: 그리드 css 반응형에 맞춰 구현

* feat: 모달 반응형 구체화

* test: 모달창 렌더링 테스트

* fix: 잘못된 카테고리 요소가 렌더링되는 버그 해결

* fix: 잘못된 액션 인자 전달 수정
  • Loading branch information
2yunseong authored Mar 31, 2023
1 parent 3f9c0b0 commit cc2e598
Show file tree
Hide file tree
Showing 11 changed files with 537 additions and 52 deletions.
20 changes: 20 additions & 0 deletions cypress/e2e/movie-e2e.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", "첫번째 테스트");
});
});
56 changes: 56 additions & 0 deletions cypress/fixtures/detail.json
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
</head>
<body>
<app-component></app-component>
<modal-component></modal-component>
</body>
</html>
94 changes: 87 additions & 7 deletions src/components/AppComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,38 @@ 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;
#totalPage;
#$movieList;
#$movieListTitle;
#$searchInput;
#scrollThrottleId;
#actionType;

render() {
super.render();

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);
Expand Down Expand Up @@ -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;

Expand All @@ -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:
Expand All @@ -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;
}
});
Expand All @@ -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() {
Expand All @@ -132,6 +211,7 @@ export default class AppComponent extends CustomComponent {
<more-button></more-button>
</section>
</main>
<up-scroll-button class="hide"></up-scroll-button>
</div>
`;
}
Expand Down
131 changes: 118 additions & 13 deletions src/components/ModalComponent.js
Original file line number Diff line number Diff line change
@@ -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 `
<div class="modal-body">
<div class="content-header">
<div class="modal-content-header side-padding">
<div class="dummy"></div>
<h3>영화 이름</h3>
<button>닫기 버튼</button>
</div>
<div class="content-body">
<img alt="영화 포스터 이미지">
<div class="movie-details">
<div class="movie-detail-header">
<p>장르</p>
<div>별점</div>
<button class="modal-close-button" data-action="modal-close">X</button>
</div>
<div class="modal-content-body side-padding">
<img class="main-poster-img" alt="영화 포스터 이미지">
<div class="movie-details">
<section>
<div class="movie-detail-header">
<p class="movie-detail-genre"></p>
<img src=${StarFilledImg} />
<div class="movie-detail-vote-average"></div>
</div>
<div class="movie-detail-description"></div>
</section>
<div class="custom-rate">
<h4>내 별점</h4>
<button class="rate-button" data-action-rate="2"><img src=${StarEmptyImg}></button>
<button class="rate-button" data-action-rate="4"><img src=${StarEmptyImg}></button>
<button class="rate-button" data-action-rate="6"><img src=${StarEmptyImg}></button>
<button class="rate-button" data-action-rate="8"><img src=${StarEmptyImg}></button>
<button class="rate-button" data-action-rate="10"><img src=${StarEmptyImg}></button>
<p class="select-rate-score"></p>
<p class="select-rate-text">별점을 매겨주세요.</p>
</div>
</div>
<div> 설명입니다. 설명입니다 </div>
<div> 평점 매기기 </div>
</div>
</div>
`;
}
Expand Down
Loading

0 comments on commit cc2e598

Please sign in to comment.