From 3f9c0b0a9ee3aa906073bf2544018118509c1715 Mon Sep 17 00:00:00 2001 From: 2yunseong <56749516+2yunseong@users.noreply.github.com> Date: Sat, 25 Mar 2023 15:33:18 +0900 Subject: [PATCH] =?UTF-8?q?[1=EB=8B=A8=EA=B3=84=20-=20=EC=98=81=ED=99=94?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0]?= =?UTF-8?q?=20=EC=9C=A4=EC=83=9D(=EC=9D=B4=EC=9C=A4=EC=84=B1)=20=EB=AF=B8?= =?UTF-8?q?=EC=85=98=20=EC=A0=9C=EC=B6=9C=ED=95=A9=EB=8B=88=EB=8B=A4.=20(#?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: 기능 요구 사항 작성 Co-authored-by: 2yunseong <2yunseong@users.noreply.github.com> * feat: 컴포넌트 분리 구현 * feat: 이미지 밑 스타일 임포트 * feat: 인기 영화 데이터 fetch 후 render 구현 * feat: 인기 영화 더보기 기능 구현 * feat: 영화 검색 기능 구현 * feat: API 키 ignore * refactor: 컴포넌트 폴더 분리 * feat: 영화 API 페이지 상태관리 * refactor: 중복되는 api 호출 로직 모듈화 * refactor: 중복되는 렌더링 로직 모듈화 * refactor: initialRender 메서드 변경 * feat: 에러에 따른 렌더링 구현 * feat: 에러 컴포넌트 구현 * test: 영화 리뷰 미션 e2e 테스트 작성 Co-authored-by: 2yunseong <2yunseong@users.noreply.github.com> * refactor: Movie List Deprecated 된 코드 제거 * refactor: console.log 제거 * refactor: MoreButton 컴포넌트 코드 가독성 개선 * refactor: 코드 상수화 * refactor: event 위임 로직 if문 switch문으로 변경 * refactor: Movie list HTML 구조 변경 * refactor: header HTML 구조 변경 * refactor: Movie List Title HTML 구조 변경 * refactor: App 내의 불필요한 document 선언 제거 * refactor: Skeleton 불필요한 li태그 제거 * refactor: App 내부 변수명 통일 * refactor: App 불필요한 필드 제거 * refactor: List Method 네이밍 변경 * refactor: API 처리 로직 구조 변경 * feat: 로고 클릭시 input value 초기화 * refactor: url 생성 책임 분리 * refactor: App 더보기 버튼 액션 변경 메소드 분리 * refactor: App 컴포넌트 코드 가독성 개선 * refactor: TITLE 상수화 객체 타입 단언 * refactor: 컴포넌트 전체적 가독성 개선 * refactor: constants 디렉토리 변경 * docs: README.md 작성 * refactor: 사용되지 않는 매개변수 선언 제거 * fix: html 마크업 수정 * refactor: cypress 불필요한 파일 제거 * refactor: 웹 접근성 개선 * refactor: 마크업 구조 변경에 따른 테스트 수정 * chore: 기술에 대한 정보를 제공하는 주석 추가 * refactor: early return / 모호한 네이밍 수정 * test: 검색창 공백 입력 시/ 입력이 비어있을 시 테스트 추가 * fix: 공백을 입력 시 검색을 하는 오류 수정 * test: 테스트 명 수정 * feat: 오류 메세지 페이지에 CTA 버튼 추가 * refactor: fetch 재사용화를 위한 책임 분리 * fix: totalPage 상태값 갱신하지 않은 오류 해결 * test: 엘리먼트 선택자 변경 * refactor: custom component 네이밍 변경 --------- Co-authored-by: SangwonKang <72087183+ksone02@users.noreply.github.com> Co-authored-by: 2yunseong <2yunseong@users.noreply.github.com> --- .gitignore | 2 + README.md | 42 +++ cypress.config.ts | 10 + cypress/e2e/movie-e2e.cy.ts | 190 ++++++++++ cypress/fixtures/movie-popular.json | 327 ++++++++++++++++++ docs/REQUIREMENTS.md | 17 + index.html | 6 +- src/abstracts/CustomComponent.js | 14 + src/abstracts/type.ts | 21 ++ src/api/handler.js | 32 ++ src/api/url.js | 11 + src/components/AppComponent.js | 140 ++++++++ src/components/AppHeaderComponent.js | 16 + src/components/ModalComponent.js | 26 ++ src/components/element/MoreButtonComponent.js | 20 ++ src/components/element/TitleComponent.js | 9 + src/components/movie/ErrorComponent.js | 16 + src/components/movie/MovieComponent.js | 28 ++ src/components/movie/MovieListComponent.js | 46 +++ .../movie/MovieListPageComponent.js | 54 +++ .../movie/MovieSkeletonComponent.js | 17 + src/constants/constants.ts | 23 ++ src/index.js | 6 + src/util/MovieList.ts | 16 + templates/common.css | 145 +++++++- templates/error.png | Bin 0 -> 1167 bytes webpack.config.js | 1 + 27 files changed, 1219 insertions(+), 16 deletions(-) create mode 100644 cypress.config.ts create mode 100644 cypress/e2e/movie-e2e.cy.ts create mode 100644 cypress/fixtures/movie-popular.json create mode 100644 src/abstracts/CustomComponent.js create mode 100644 src/abstracts/type.ts create mode 100644 src/api/handler.js create mode 100644 src/api/url.js create mode 100644 src/components/AppComponent.js create mode 100644 src/components/AppHeaderComponent.js create mode 100644 src/components/ModalComponent.js create mode 100644 src/components/element/MoreButtonComponent.js create mode 100644 src/components/element/TitleComponent.js create mode 100644 src/components/movie/ErrorComponent.js create mode 100644 src/components/movie/MovieComponent.js create mode 100644 src/components/movie/MovieListComponent.js create mode 100644 src/components/movie/MovieListPageComponent.js create mode 100644 src/components/movie/MovieSkeletonComponent.js create mode 100644 src/constants/constants.ts create mode 100644 src/util/MovieList.ts create mode 100644 templates/error.png diff --git a/.gitignore b/.gitignore index 3c3629e64..a83496dfa 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ node_modules + +src/constants/key.js diff --git a/README.md b/README.md index aa2286f8e..5d3691bd0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,45 @@ # javascript-movie-review FE 5기 레벨1 영화관 미션 + +## 페어프로그래밍 룰 + +1. 드라이버가 주도 네비게이터가 조언 +2. 드라이버 변경시간 30분 +3. 네비게이터는 집중을 잃지 않는다. +4. 2시간마다 15분 휴식 +5. 모르는 부분 / 논의가 필요한 부분 나오면 타이머 중단 +6. 화장실이나 급한 사정은 유도리 있게 + +## 컴포넌트를 분리하려는 목적 + +1. 재사용성이 좋아진다. + - 문제 요구사항을 분석했을 때, 재사용 되는 요소들이 많았다고 생각이 들었다.(더보기 버튼, 영화 목록 아이템, 페이지 타이틀) + - 또한, 앱의 기능이 확장 되었을 때, 재사용 하려 만든 요소를 이용해 쉽게 기능을 확장할 수 있다고 판단이 들었다. +2. 가독성이 좋아진다. + - 파일을 컴포넌트 단위로 나누고, 컴포넌트를 UI, 기능별로 나누어 한 파일에 한 UI, 기능 단위의 로직만 들어가게 되어 개발자 입장에서 보기 좋은 프로젝트가 된다. +3. 독립성이 향상된다. + - 컴포넌트로 나누게 되면 각각의 다른 컴포넌트(기능)들의 간섭을 덜 받게 되어 로직의 안정성이 높아진다. + +## 개발 환경 + +- node v16.14.0 +- npm 8.3.1 + +## API Key 설정 방법 + +- API 키를 숨기기 위해 레퍼지토리에서 숨김 처리 해두었습니다. + +1. src/constants 디렉토리 하위에 key.js 생성 +2. 다음의 내용 입력 + +```javascript +export const API_KEY = "사용할 API 키"; +``` + +## e2e 테스트 방법 + +1. npm run start로 local server 구동(포트 8081 고정) +2. npm run test-e2e로 cypress 구동 +3. E2E-Testing -> Start E2E Testing in Chrome 클릭 +4. cypress 창이 뜨면 movie-e2e.cy.ts 클릭 diff --git a/cypress.config.ts b/cypress.config.ts new file mode 100644 index 000000000..3b44444d4 --- /dev/null +++ b/cypress.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "cypress"; + +export default defineConfig({ + e2e: { + setupNodeEvents(on, config) { + // implement node event listeners here + }, + supportFile: false, + }, +}); diff --git a/cypress/e2e/movie-e2e.cy.ts b/cypress/e2e/movie-e2e.cy.ts new file mode 100644 index 000000000..64302a29f --- /dev/null +++ b/cypress/e2e/movie-e2e.cy.ts @@ -0,0 +1,190 @@ +describe("인기 영화 목록 페이지", () => { + beforeEach(() => { + cy.visit("http://localhost:8081/"); + cy.viewport(1920, 1080); + }); + + it("초기 접속 시에 인기 영화목록 상위 20개가 화면에 출력된다.", () => { + cy.get("movie-list-page").find("movie-item").should("exist"); + cy.get("movie-list-page").children().should("have.length", 20); + }); + + it("헤더 로고 클릭 시 처음 인기 영화목록이 출력된다.", () => { + cy.get("img[data-action='popular']").click(); + + cy.get("movie-list-page").find("movie-item").should("exist"); + cy.get("movie-list-page").should("exist"); + }); +}); + +describe("인기 영화 목록 더보기", () => { + it("사용자가 마지막 페이지에 도달했을 때, 더보기 버튼이 화면에서 보이지 않아야 한다.", () => { + cy.intercept( + { + method: "GET", + url: /^https:\/\/api.themoviedb.org\/3\/movie\/popular*/, + }, + { fixture: "movie-popular.json" } + ).as("getPopularMovies"); + + cy.visit("http://localhost:8081/"); + cy.viewport(1920, 1080); + + cy.wait("@getPopularMovies").then(() => { + cy.get("button[data-action='more_popular']").should("not.be.visible"); + }); + }); + it("인기 영화 페이지에서 더보기 버튼 클릭시 영화 목록이 추가된다.", () => { + cy.visit("http://localhost:8081/"); + cy.viewport(1920, 1080); + + cy.get("button[data-action='more_popular']").click(); + + cy.get("movie-list-page").find("movie-item").should("exist"); + cy.get("movie-list-page").should("have.length", 2); + }); +}); + +describe("검색 목록 확인", () => { + it("검색창에 해리포터를 입력하고 검색버튼 클릭시, 제목에 '해리포터'가 포함된 영화 목록이 화면에 나온다.", () => { + cy.visit("http://localhost:8081/"); + cy.viewport(1920, 1080); + + cy.get(".search-box").find("input").type("해리포터"); + cy.get("button[data-action='search']") + .click() + .then(() => { + cy.get(".item-title").each(() => { + cy.get("h3.item-title").contains(/해\s*리\s*포\s*터/); + }); + }); + }); + + it("검색창에 해리포터를 입력하고 엔터키를 누르면, 제목에 '해리포터'가 포함된 영화 목록이 화면에 나타난다.", () => { + cy.visit("http://localhost:8081/"); + cy.viewport(1920, 1080); + + cy.get(".search-box") + .find("input") + .type("해리포터{enter}") + .then(() => { + cy.get(".item-title").each(() => { + cy.get("h3.item-title").contains(/해\s*리\s*포\s*터/); + }); + }); + }); + + it("검색창에 검색결과가 없는 검색어를 입력한 후 검색하는데, 결과가 존재하지 않을 시 검색결과 없음 페이지가 화면에 나타난다.", () => { + cy.visit("http://localhost:8081/"); + cy.viewport(1920, 1080); + + cy.get(".search-box") + .find("input") + .type("sdmnfbikrdnfivjdnfodmfhfidff{enter}") + .then(() => { + cy.get(".no-result-title").contains("검색 결과를 찾지 못하였습니다."); + }); + }); + + it("검색창에 입력을 하지 않고 엔터키를 누르면 사용자에게 검색어가 없다는 경고창이 화면에 나온다.", () => { + cy.visit("http://localhost:8081/"); + cy.viewport(1920, 1080); + + const stub = cy.stub(); + + cy.on("window:alert", stub); + + cy.get(".search-box") + .find("input") + .type("{enter}") + .then(() => { + expect(stub.getCall(0)).to.be.calledWith("검색어를 입력해주세요."); + }); + }); + + it("검색창에 입력을 하지 않고 검색버튼을 클릭하면 사용자에게 검색어가 없다는 경고창이 화면에 나온다.", () => { + cy.visit("http://localhost:8081/"); + cy.viewport(1920, 1080); + + const stub = cy.stub(); + + cy.on("window:alert", stub); + + cy.get(".search-button") + .click() + .then(() => { + expect(stub.getCall(0)).to.be.calledWith("검색어를 입력해주세요."); + }); + }); + + it("검색창에 공백만을 입력하고 검색버튼을 클릭하면 사용자에게 검색어가 없다는 경고창이 화면에 나온다.", () => { + cy.visit("http://localhost:8081/"); + cy.viewport(1920, 1080); + + const stub = cy.stub(); + cy.on("window:alert", stub); + + cy.get(".search-box").find("input").type(" "); + cy.get(".search-button") + .click() + .then(() => { + expect(stub.getCall(0)).to.be.calledWith("검색어를 입력해주세요."); + }); + }); + + it("검색창에 공백만을 입력하고 엔터키를 누르면 사용자에게 검색어가 없다는 경고창이 화면에 나온다.", () => { + cy.visit("http://localhost:8081/"); + cy.viewport(1920, 1080); + + const stub = cy.stub(); + cy.on("window:alert", stub); + + cy.get(".search-box") + .find("input") + .type(" {enter}") + .then(() => { + expect(stub.getCall(0)).to.be.calledWith("검색어를 입력해주세요."); + }); + }); +}); + +describe("검색목록 더보기 확인", () => { + it("검색결과 페이지에서 더보기 버튼 클릭시 검색목록이 추가된다. ", () => { + cy.visit("http://localhost:8081/"); + cy.viewport(1920, 1080); + + cy.get(".search-box") + .find("input") + .type("전쟁{enter}") + .then(() => { + cy.get("button[data-action='more_search']").click(); + + cy.get("movie-list-page").find("movie-item").should("exist"); + cy.get("movie-list-page").should("have.length", 2); + }) + .then(() => { + cy.get("h3.item-title").each(() => { + cy.get("h3.item-title").contains(/전\s*쟁/); + }); + }); + }); +}); + +describe("네트워크 에러 확인", () => { + it("네트워크 접속 오류 시 에러 페이지가 화면에 나타난다.", () => { + cy.intercept( + { + method: "GET", + url: /^https:\/\/api.themoviedb.org\/3\/movie\/popular*/, + }, + { statusCode: 401 } + ).as("getErrorData"); + + cy.visit("http://localhost:8081/"); + cy.viewport(1920, 1080); + + cy.wait("@getErrorData").then(() => { + cy.get(".error-title").contains("오류"); + }); + }); +}); diff --git a/cypress/fixtures/movie-popular.json b/cypress/fixtures/movie-popular.json new file mode 100644 index 000000000..0257fc0db --- /dev/null +++ b/cypress/fixtures/movie-popular.json @@ -0,0 +1,327 @@ +{ + "page": 1, + "results": [ + { + "adult": false, + "backdrop_path": "/jr8tSoJGj33XLgFBy6lmZhpGQNu.jpg", + "genre_ids": [16, 12, 35, 10751], + "id": 315162, + "original_language": "en", + "original_title": "Puss in Boots: The Last Wish", + "overview": "아홉 개의 목숨 중 단 하나의 목숨만 남은 장화신은 고양이. 마지막 남은 목숨을 지키기 위해 히어로의 삶 대신 반려묘의 삶을 선택한 그에게 찾아온 마지막 기회, 바로 소원을 들어주는 소원별이 있는 곳을 알려주는 지도! 잃어버린 목숨을 되찾고 다시 히어로가 되기를 꿈꾸는 장화신은 고양이는 뜻밖에 동료가 된 앙숙 파트너 '키티 말랑손', 그저 친구들과 함께라면 모든 게 행복한 강아지 '페로'와 함께 소원별을 찾기 위해 길을 떠난다. 그리고 소원별을 노리는 또 다른 빌런들과 마주치게 되는데…", + "popularity": 1972.345, + "poster_path": "/rKgvctIuPXyuqOzCQ16VGdnHxKx.jpg", + "release_date": "2022-12-07", + "title": "장화신은 고양이: 끝내주는 모험", + "video": false, + "vote_average": 8.4, + "vote_count": 4613 + }, + { + "adult": false, + "backdrop_path": "/2Eewgp7o5AU1xCataDmiIL2nYxd.jpg", + "genre_ids": [18, 36], + "id": 943822, + "original_language": "en", + "original_title": "Prizefighter: The Life of Jem Belcher", + "overview": "", + "popularity": 2522.636, + "poster_path": "/x3PIk93PTbxT88ohfeb26L1VpZw.jpg", + "release_date": "2022-06-30", + "title": "Prizefighter: The Life of Jem Belcher", + "video": false, + "vote_average": 6.8, + "vote_count": 38 + }, + { + "adult": false, + "backdrop_path": "/r7Dfg9aRZ78gJsmDlCirIIlNH3d.jpg", + "genre_ids": [18], + "id": 785084, + "original_language": "en", + "original_title": "The Whale", + "overview": "272kg의 거구로 세상을 거부한 채 살아가는 대학 강사 찰리는 남은 시간이 얼마 없음을 느끼고 오랫동안 만나지 못한 10대 딸 엘리를 집으로 초대한다. 그리고, 매일 자신을 찾아와 에세이 한 편을 완성하면 전 재산을 주겠다고 제안한다.", + "popularity": 2029.776, + "poster_path": "/q0xV5Lnq30JiegbVGBOvVwrgUDT.jpg", + "release_date": "2022-12-09", + "title": "더 웨일", + "video": false, + "vote_average": 8, + "vote_count": 1224 + }, + { + "adult": false, + "backdrop_path": "/xDMIl84Qo5Tsu62c9DGWhmPI67A.jpg", + "genre_ids": [28, 12, 878], + "id": 505642, + "original_language": "en", + "original_title": "Black Panther: Wakanda Forever", + "overview": "국왕이자 블랙 팬서인 티찰라의 죽음 이후 수많은 강대국으로부터 위협을 받게 된 와칸다. 라몬다, 슈리 그리고 나키아, 오코예, 음바쿠는 각자 사명감을 갖고 와칸다를 지키기 위해 외로운 싸움을 이어간다. 한편, 비브라늄의 패권을 둘러싼 미스터리한 음모와 함께 깊은 해저에서 모습을 드러낸 최강의 적 네이머와 탈로칸의 전사들은 와칸다를 향해 무차별 공격을 퍼붓기 시작하는데…", + "popularity": 1798.789, + "poster_path": "/3PCRWLeqp5y20k6XVzcamZR3BWF.jpg", + "release_date": "2022-11-09", + "title": "블랙 팬서: 와칸다 포에버", + "video": false, + "vote_average": 7.3, + "vote_count": 4097 + }, + { + "adult": false, + "backdrop_path": "/22z44LPkMyf5nyyXvv8qQLsbom.jpg", + "genre_ids": [27, 9648, 53], + "id": 631842, + "original_language": "en", + "original_title": "Knock at the Cabin", + "overview": "휴가를 떠난 한 가족은 별장에 무단침입한 낯선 방문자들과 대치하게 된다. ‘레너드’(데이브 바티스타)와 낯선 방문자들은 세상의 종말을 막으러 왔다며, 가족 중 한 명을 희생시켜야만 인류의 멸망을 막을 수 있다는 잔혹한 선택을 하게 하는데… 가족을 살리면 인류가 멸망하고, 인류를 살리면 가족이 죽는다!", + "popularity": 1631.885, + "poster_path": "/mCDSOfcVJfMkGUNrNpXWFO7oNBY.jpg", + "release_date": "2023-02-01", + "title": "똑똑똑", + "video": false, + "vote_average": 6.4, + "vote_count": 1010 + }, + { + "adult": false, + "backdrop_path": "/pxJbfnMIQQxCrdeLD0zQnWr6ouL.jpg", + "genre_ids": [28, 35, 53], + "id": 1077280, + "original_language": "en", + "original_title": "Die Hart", + "overview": "가상의 케빈 하트가 액션 영화 배우가 되기 위해 노력하는 과정을 따라가 보세요. 그는 론 윌콕스가 운영하는 학교에 다니며 업계에서 가장 탐내는 액션 스타가 되기 위한 방법을 배우려고 노력합니다.", + "popularity": 1398.005, + "poster_path": "/1EnBjTJ5utgT1OXYBZ8YwByRCzP.jpg", + "release_date": "2023-02-22", + "title": "다이 하트: 더 무비", + "video": false, + "vote_average": 6.2, + "vote_count": 180 + }, + { + "adult": false, + "backdrop_path": "/AsEgGeccI32SwMBkxpwhOkhbzmF.jpg", + "genre_ids": [28, 878, 53, 27], + "id": 1011679, + "original_language": "en", + "original_title": "Shark Side of the Moon", + "overview": "", + "popularity": 1383.229, + "poster_path": "/v5CfpzxoJDkZxjZAizClFdlEF0U.jpg", + "release_date": "2022-08-12", + "title": "Shark Side of the Moon", + "video": false, + "vote_average": 5.3, + "vote_count": 89 + }, + { + "adult": false, + "backdrop_path": "/9Rq14Eyrf7Tu1xk0Pl7VcNbNh1n.jpg", + "genre_ids": [28, 12, 53], + "id": 646389, + "original_language": "en", + "original_title": "Plane", + "overview": "평소와 다를 것 없던 어느 날, 기장 토렌스는 비행기 운행 중 거대한 폭풍우를 만나 정체모를 섬에 불시착한다. 살아남았다는 기쁨도 잠시, 비행기를 탈취하려는 무장단체의 인질극이 시작되고 토렌스는 탑승객 중 가장 피해야 할 의문의 살인범 가스파레와 손을 잡고 승객들과 함께 탈출을 시도하는데… 무장단체의 표적은 단 하나, 토렌스와 탑승객들! 목숨을 건 위험한 탈출 작전이 시작된다!", + "popularity": 1204.689, + "poster_path": "/8XDLgSlM6hb01iEWNRbqxKdFnGD.jpg", + "release_date": "2023-01-12", + "title": "플레인", + "video": false, + "vote_average": 6.9, + "vote_count": 881 + }, + { + "adult": false, + "backdrop_path": "/5i6SjyDbDWqyun8klUuCxrlFbyw.jpg", + "genre_ids": [18], + "id": 677179, + "original_language": "en", + "original_title": "Creed III", + "overview": "록키의 후계자인 크리드는 월드 챔피언으로 권투계를 장악한 후 완벽한 삶을 살던 중 과거 친형제 같았던 데미안과 재회한다. 오랜 시간 감옥 생활을 마치고 나온 데미안은 그가 꿈꾸던 인생을 사는 크리드와 충돌하며 그의 모든 것을 빼앗으려 하고, 크리드는 이에 맞서 자신의 미래를 지키기 위한 일생일대의 대결을 준비하는데…", + "popularity": 1118.721, + "poster_path": "/56HGRCoEY2OmO1yZro77IvldDpE.jpg", + "release_date": "2023-03-01", + "title": "크리드 3", + "video": false, + "vote_average": 7, + "vote_count": 339 + }, + { + "adult": false, + "backdrop_path": "/ovM06PdF3M8wvKb06i4sjW3xoww.jpg", + "genre_ids": [878, 12, 28], + "id": 76600, + "original_language": "en", + "original_title": "Avatar: The Way of Water", + "overview": "판도라 행성에서 제이크 설리와 네이티리가 이룬 가족이 겪게 되는 무자비한 위협과 살아남기 위해 떠나야 하는 긴 여정과 전투, 그리고 견뎌내야 할 상처에 대한 이야기를 그렸다. 살아남기 위해 설리 가족이 숲에서 바다로 터전을 옮기면서 겪게 되는 화합의 과정, 그리고 곳곳에서 도사리는 새로운 위협까지 역경 속에서 더 아름답게 펼쳐진다.", + "popularity": 956.637, + "poster_path": "/z56bVX93oRG6uDeMACR7cXCnAbh.jpg", + "release_date": "2022-12-14", + "title": "아바타: 물의 길", + "video": false, + "vote_average": 7.7, + "vote_count": 5748 + }, + { + "adult": false, + "backdrop_path": "/tt79dbOPd9Z9ykEOpvckttgYXwH.jpg", + "genre_ids": [28, 12, 878], + "id": 545611, + "original_language": "en", + "original_title": "Everything Everywhere All at Once", + "overview": "남편과 세탁소를 운영하며 근근히 살아가고 있는 이민자 이블린 왕. 20대 시절 남편과 순수한 사랑을 불태우며 모든 것을 뒤로 하고 미국으로 이민을 떠났지만, 50대가 된 지금은 세탁소 운영 등 온갖 일로 허덕이며 단란했던 가족과도 삐그덕거리는 등 고달프게 살고 있다. 세무조사를 받던 중 자신이 멀티버스의 여러 다른 자신들과 연결되어 있음을 알게 되고, 강력한 악의 기운으로부터 모든 우주와 가족을 구해내기 위한 모험에 나서게 되는데...", + "popularity": 1335.103, + "poster_path": "/aCW0sC55bFnOaUBP2eUGryvAJXf.jpg", + "release_date": "2022-03-24", + "title": "에브리씽 에브리웨어 올 앳 원스", + "video": false, + "vote_average": 7.9, + "vote_count": 3727 + }, + { + "adult": false, + "backdrop_path": "/ae4xZiU7IeFVrvXxm2GjdcTrBm.jpg", + "genre_ids": [27, 9648, 53], + "id": 934433, + "original_language": "en", + "original_title": "Scream VI", + "overview": "", + "popularity": 821.579, + "poster_path": "/aePBN8ffLCHoUXp8lkA5P29CXdx.jpg", + "release_date": "2023-03-08", + "title": "스크림 6", + "video": false, + "vote_average": 7.5, + "vote_count": 237 + }, + { + "adult": false, + "backdrop_path": "/k4V6EvpcOsu8CX10JD0H53lFXLq.jpg", + "genre_ids": [53, 28, 18, 80], + "id": 1058949, + "original_language": "en", + "original_title": "Little Dixie", + "overview": "부패한 주지사와 무자비한 마약왕 사이의 거래가 실패로 돌아가자 전직 특수부대 요원 닥(그릴로)이 용의선상에 오르게 됩니다. 이제 닥은 가족이 위험에 처한 상황에서 멕시코 마약 카르텔을 소탕하고 자신의 인생에서 유일하게 긍정적인 존재인 어린 딸 '리틀 딕시'를 지키기 위해 무엇이든 해야 합니다.", + "popularity": 918.963, + "poster_path": "/cmWTZj9zzT9KFt3XyL0gssL7Ig8.jpg", + "release_date": "2023-02-03", + "title": "리틀 딕시", + "video": false, + "vote_average": 6, + "vote_count": 77 + }, + { + "adult": false, + "backdrop_path": "/dlrWhn0G3AtxYUx2D9P2bmzcsvF.jpg", + "genre_ids": [878, 27, 35], + "id": 536554, + "original_language": "en", + "original_title": "M3GAN", + "overview": "교통사고로 부모를 잃고 혼자가 된 소녀 ‘케이디’. 로봇 엔지니어이자, ‘케이디’의 보호자가 된 ‘젬마’는 ‘케이디’를 안전하게 지켜야 하는 프로그램이 입력된 AI 로봇 ‘메간’을 선물한다. 메간은 언제나 ‘케이디’의 곁을 지켜주며 함께 웃고, 힘들 땐 위로해주는 둘도 없는 친구가 된다. 어느 날, ‘케이디’가 위험에 처하자 ‘메간’은 예측할 수 없는 방향으로 업그레이드되며 감당할 수 없는 일들이 벌어지는데…", + "popularity": 843.959, + "poster_path": "/v0TtgXaZZ6NeDxKackgQK9byEDL.jpg", + "release_date": "2022-12-28", + "title": "메간", + "video": false, + "vote_average": 7.4, + "vote_count": 2048 + }, + { + "adult": false, + "backdrop_path": "/af1tFYzzT2mHIy2l7nTIU8PWfak.jpg", + "genre_ids": [80, 18, 53], + "id": 842942, + "original_language": "en", + "original_title": "Bandit", + "overview": "탈옥 후 신분을 위장한 채 여러 은행을 털며 살아가는 남자의 이야기를 다룬 영화", + "popularity": 819.926, + "poster_path": "/yph9PAbmjYPvyvbeZvdYIhCZHEu.jpg", + "release_date": "2022-09-23", + "title": "뱅크 맨", + "video": false, + "vote_average": 6.1, + "vote_count": 123 + }, + { + "adult": false, + "backdrop_path": "/v2LilmCylr3bL9TCZSj6syjowZh.jpg", + "genre_ids": [35, 18], + "id": 937278, + "original_language": "en", + "original_title": "A Man Called Otto", + "overview": "모든 것을 포기하려는 ‘오토’(톰 행크스)는 죽고 싶을 타이밍마다 이를 방해하는 이웃들로 분노가 치밀어 오른다. 인생 최악의 순간, 뜻하지 않은 이웃들과의 사건들로 인해 상황은 생각지도 못한 방향으로 흘러가는데…", + "popularity": 732.514, + "poster_path": "/bC0e8GRmi8pHnkXHPbKqUKo9Kxr.jpg", + "release_date": "2022-12-28", + "title": "오토라는 남자", + "video": false, + "vote_average": 7.9, + "vote_count": 736 + }, + { + "adult": false, + "backdrop_path": "/xnAi4BRoO3ZQ3wwxGn6UNoxQzDq.jpg", + "genre_ids": [80, 28], + "id": 823999, + "original_language": "it", + "original_title": "Diabolik - Ginko all'attacco!", + "overview": "", + "popularity": 774.758, + "poster_path": "/8QVbWBv94BAT9u1q9uJccwOxMzt.jpg", + "release_date": "2022-11-17", + "title": "Diabolik - Ginko all'attacco!", + "video": false, + "vote_average": 5.6, + "vote_count": 103 + }, + { + "adult": false, + "backdrop_path": "/32GH8Mi4GmTPIQyd6IW1FFrHWrj.jpg", + "genre_ids": [28], + "id": 965839, + "original_language": "en", + "original_title": "Lord of the Streets", + "overview": "", + "popularity": 776.661, + "poster_path": "/n9YWVQRc0zw0nwrFcOkOpffZxjc.jpg", + "release_date": "2022-04-22", + "title": "Lord of the Streets", + "video": false, + "vote_average": 5.4, + "vote_count": 44 + }, + { + "adult": false, + "backdrop_path": "/gslT8t964rYXyqRcqrxFh77ikyb.jpg", + "genre_ids": [12, 878, 35], + "id": 640146, + "original_language": "en", + "original_title": "Ant-Man and the Wasp: Quantumania", + "overview": "슈퍼히어로 파트너인 스캇 랭과 호프 반 다인, 호프의 부모 재닛 반 다인과 행크 핌, 그리고 스캇의 딸 캐시 랭까지 미지의 양자 영역 세계 속에 빠져버린 앤트맨 패밀리. 그 곳에서 새로운 존재들과 무한한 우주를 다스리는 정복자 캉을 만나며, 그 누구도 예상 못 한 모든 것의 한계를 뛰어넘는 모험을 시작하게 되는데…", + "popularity": 758.067, + "poster_path": "/cw6jBnTauNmEEIIXcoNEyoQItG7.jpg", + "release_date": "2023-02-15", + "title": "앤트맨과 와스프: 퀀텀매니아", + "video": false, + "vote_average": 6.4, + "vote_count": 1017 + }, + { + "adult": false, + "backdrop_path": "/zGoZB4CboMzY1z4G3nU6BWnMDB2.jpg", + "genre_ids": [28, 10749, 35], + "id": 758009, + "original_language": "en", + "original_title": "Shotgun Wedding", + "overview": "‘다아시’(제니퍼 로페즈)와 ‘톰’(조쉬 더하멜)의 결혼식 당일, 우여곡절 끝에 결혼식에 참석할 모두가 섬에 모인다. 모든 게 순조로워 보이던 그때! 갑자기 들이닥친 해적으로 인해 결혼식장의 모두가 인질이 되고… ‘다아시’와 ‘톰’은 무사히 혼인서약을 마치기 위해 목숨을 건 버진 로드를 걷게 되는데… 죽이든가, 죽든가! 2023년 3월, 가장 화X한 웨딩이 온다!", + "popularity": 742.959, + "poster_path": "/aGhII6CTTvpZwnElzAUB7ZbgjTN.jpg", + "release_date": "2022-12-28", + "title": "샷건 웨딩", + "video": false, + "vote_average": 6.3, + "vote_count": 650 + } + ], + "total_pages": 1, + "total_results": 20 +} diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index e69de29bb..90f7952fa 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -0,0 +1,17 @@ +# 기능 목록 +- 영화 목록 조회 + - 영화 목록의 1페이지를 불러오며 더보기 버튼을 누르면 그 다음의 영화 목록을 불러 올 수 있다 단, 페이지 끝에 도달한 경우에는 더보기 버튼을 화면에 출력하지 않는다. ( 1페이지 20개의 영화 ) + - 영화 목록 아이템에 대한 Skeleton UI를 구현한다. Skeleton UI는 템플릿으로 제공되는 파일 이외로 자유롭게 구현할 수 있다. +- 영화 검색 + - 영화 검색 API를 이용하여 내가 보고 싶은 영화를 검색할 수 있다. + - 엔터키를 눌러 검색할 수 있다 + - 검색 버튼을 클릭하여 검색할 수 있다 + - 영화 목록 조회와 같이 검색한 결과에 한해 정보를 보여주는 화면의 요구사항은 동일하다 + - Skeleton UI를 구현한다 + - 마찬가지로 더보기 버튼 구현 +- 오류 핸들링 + - 더보기 눌렀을 때 네트워크 접속이 끊겼으면 alert 띄우기 + - 검색 결과가 없을 때 찾으시는 검색 결과가 없다는 블럭 띄우기 + - 영화 목록 조회 네트워크 접속 에러시 네트워크 에러 블럭 띄우기 +- 헤더 + - 로고를 눌렀을 때 처음 영화 조회 페이지로 이동 \ No newline at end of file diff --git a/index.html b/index.html index 5efa25cfa..8a852d2e4 100644 --- a/index.html +++ b/index.html @@ -1,12 +1,12 @@ - + - Document + 🎬 Movie List - 영화 리뷰 미션을 진행하세요 + diff --git a/src/abstracts/CustomComponent.js b/src/abstracts/CustomComponent.js new file mode 100644 index 000000000..4393fd7e0 --- /dev/null +++ b/src/abstracts/CustomComponent.js @@ -0,0 +1,14 @@ +export default class CustomComponent extends HTMLElement { + connectedCallback() { + this.render(); + this.handleEvent(); + } + + render() { + this.innerHTML = this.template(); + } + + template() {} + + handleEvent() {} +} diff --git a/src/abstracts/type.ts b/src/abstracts/type.ts new file mode 100644 index 000000000..ed4abb55b --- /dev/null +++ b/src/abstracts/type.ts @@ -0,0 +1,21 @@ +export type MovieItem = { + poster_path: string; + adult: boolean; + overview: string; + release_date: string; + genre_ids: number[]; + id: number; + original_title: string; + original_language: string; + title: string; + backdrop_path: string; + popularity: number; + vote_count: number; + video: boolean; + vote_average: number; +}; + +export type MovieElementData = Pick< + MovieItem, + "poster_path" | "id" | "title" | "vote_average" +>; diff --git a/src/api/handler.js b/src/api/handler.js new file mode 100644 index 000000000..83e256397 --- /dev/null +++ b/src/api/handler.js @@ -0,0 +1,32 @@ +export const getRequest = (url) => { + return new Promise((resolve, reject) => { + fetch(url, { method: "GET" }) + .then((res) => { + if (!res.ok) { + reject({ status: "fail" }); + } + return res; + }) + .then((res) => res.json()) + .then((result) => resolve({ status: "success", ...result })) + .catch(() => reject({ status: "fail" })); + }); +}; + +export const transData = (payload) => { + if (payload.status === "fail") { + return { status: "fail" }; + } + return { + status: "success", + results: payload.results.map( + ({ poster_path, id, title, vote_average }) => ({ + poster_path, + id, + title, + vote_average, + }) + ), + totalPage: payload.total_pages, + }; +}; diff --git a/src/api/url.js b/src/api/url.js new file mode 100644 index 000000000..fd04bb9ad --- /dev/null +++ b/src/api/url.js @@ -0,0 +1,11 @@ +import { ACTION, REQUEST_URL } from "../constants/constants"; +import { API_KEY } from "../constants/key"; + +export const urlByActionType = (actionType, payload) => { + switch (actionType) { + case ACTION.POPULAR: + return `${REQUEST_URL}/movie/popular?api_key=${API_KEY}&language=ko-KR&page=${payload.nextPage}`; + case ACTION.SEARCH: + return `${REQUEST_URL}/search/movie?api_key=${API_KEY}&language=ko-KR&query=${payload.query}&page=${payload.nextPage}&include_adult=false`; + } +}; diff --git a/src/components/AppComponent.js b/src/components/AppComponent.js new file mode 100644 index 000000000..56b094453 --- /dev/null +++ b/src/components/AppComponent.js @@ -0,0 +1,140 @@ +import CustomComponent from "../abstracts/CustomComponent"; +import HeaderComponent from "./AppHeaderComponent"; +import MovieListComponent from "./movie/MovieListComponent"; +import MoreButtonComponent from "./element/MoreButtonComponent"; +import TitleComponent from "./element/TitleComponent"; +import transformMovieItemsType from "../util/MovieList"; +import { ACTION, SEARCH_WARNING, TITLE } from "../constants/constants"; +import { getRequest, transData } from "../api/handler"; +import { urlByActionType } from "../api/url"; + +export default class AppComponent extends CustomComponent { + #nextPage = 1; + #totalPage; + #$movieList; + #$movieListTitle; + #$searchInput; + + render() { + super.render(); + + this.#$movieList = this.querySelector("movie-list"); + this.#$movieListTitle = this.querySelector("movie-list-title"); + this.#$searchInput = this.querySelector("input"); + + this.popularListInit(); + this.getMovieData(ACTION.POPULAR); + this.changeMoreButtonAction(ACTION.MORE_POPULAR); + } + + getMovieData(actionType) { + getRequest( + urlByActionType(actionType, { + nextPage: this.#nextPage, + query: this.#$searchInput.value, + }) + ) + .then((res) => { + const data = transData(res); + this.#$movieList.renderPageSuccess(data.results); + + this.#nextPage += 1; + this.#totalPage = data.totalPage; + + this.changeButtonDisplayByPage(); + }) + .catch(() => { + this.#$movieList.renderPageFail(); + }); + } + + changeButtonDisplayByPage() { + if (this.#totalPage <= this.#nextPage) { + this.querySelector("more-button").classList.add("hide"); + return; + } + this.querySelector("more-button").classList.remove("hide"); + } + + searchListInit() { + this.#nextPage = 1; + + this.#$movieListTitle.setTitle( + `"${this.#$searchInput.value}" ${TITLE.SEARCH}` + ); + this.#$movieList.initialPage(); + } + + popularListInit() { + this.#nextPage = 1; + + this.#$searchInput.value = ""; + this.#$movieListTitle.setTitle(TITLE.POPULAR); + this.#$movieList.initialPage(); + } + + changeMoreButtonAction(actionType) { + this.querySelector("more-button").setAttribute("data-action", actionType); + } + + handleEvent() { + this.addEventListener("click", (e) => { + switch (e.target.dataset.action) { + case ACTION.POPULAR: + this.popularListInit(); + this.getMovieData(ACTION.POPULAR); + this.changeMoreButtonAction(ACTION.MORE_POPULAR); + break; + case ACTION.SEARCH: + if (!this.#$searchInput.value.trim()) { + alert(SEARCH_WARNING); + return; + } + this.searchListInit(); + this.getMovieData(ACTION.SEARCH); + this.changeMoreButtonAction(ACTION.MORE_SEARCH); + break; + case ACTION.MORE_POPULAR: + this.#$movieList.appendNewPage(); + this.getMovieData(ACTION.POPULAR); + break; + case ACTION.MORE_SEARCH: + this.#$movieList.appendNewPage(); + this.getMovieData(ACTION.SEARCH); + break; + } + }); + + this.addEventListener("keyup", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + + if (!this.#$searchInput.value.trim()) { + alert(SEARCH_WARNING); + return; + } + + this.searchListInit(); + this.getMovieData(ACTION.SEARCH); + this.changeMoreButtonAction(ACTION.MORE_SEARCH); + } + }); + } + + template() { + return ` +
+ +
+
+ + + +
+
+
+ `; + } +} + +customElements.define("app-component", AppComponent); diff --git a/src/components/AppHeaderComponent.js b/src/components/AppHeaderComponent.js new file mode 100644 index 000000000..5ddcc32cd --- /dev/null +++ b/src/components/AppHeaderComponent.js @@ -0,0 +1,16 @@ +import CustomComponent from "../abstracts/CustomComponent"; +import LogoImg from "../../templates/logo.png"; + +export default class HeaderComponent extends CustomComponent { + template() { + return ` +

MovieList 로고, 메인으로 바로가기

+ + `; + } +} + +customElements.define("app-header", HeaderComponent); diff --git a/src/components/ModalComponent.js b/src/components/ModalComponent.js new file mode 100644 index 000000000..ea59eddef --- /dev/null +++ b/src/components/ModalComponent.js @@ -0,0 +1,26 @@ +import CustomComponent from "../abstracts/CustomComponent"; + +export default class ModalComponent extends CustomComponent { + template() { + return ` +