diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..17361f3dc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,49 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +npm run dev # 개발 서버 실행 +npm run build # TypeScript 타입 체크 후 Vite 빌드 +npm run test-unit # Vitest 단위 테스트 실행 +npm run test-e2e # Cypress E2E 테스트 실행 (UI) +npm run preview # 빌드 결과물 미리보기 +``` + +단위 테스트 단일 실행: +```bash +npx vitest run --reporter=verbose <파일명> +``` + +환경변수: `VITE_TMDB_API_TOKEN`이 `.env`에 필요 (TMDB API Bearer token). + +## 아키텍처 + +3개 레이어로 구성되며 단방향 의존성을 유지한다. 아래 레이어는 위 레이어를 모른다. + +``` +Presentation → Domain → Data Source +``` + +### Data Source +- `src/movieAPIResponse.ts` — TMDB API fetch. 응답을 `MoviePage { results, totalPages }`로 정규화. 에러는 throw만 함. API 필드명(snake_case)은 이 파일 안에서만 존재해야 한다. + +### Domain +- `src/domain/MovieBrowser.ts` — 순수 상태 기계. DOM/fetch 의존성 없음. 페이지네이션 상태, 검색 모드, 배너/버튼 표시 여부를 관리. 렌더러가 필요한 값(`showsBanner`, `sectionTitle`, `isNewSession`, `canLoadMore`)을 직접 계산해서 노출한다. + +### Presentation +- `src/presentation/fetchStrategies.ts` — `FetchStrategy` 타입과 `popularStrategy` / `searchStrategy`. 모드별 fetch 전략을 교체 가능하게 분리. +- `src/presentation/MovieController.ts` — 이벤트 → 도메인 명령 → 렌더 흐름 조율. `#createNewRequest()`, `#applyResult()`, `#handleError()`로 분해됨. 에러 처리와 로딩 상태 관리 담당. +- `src/presentation/MovieRenderer.ts` — `MovieBrowser` 상태를 받아 DOM 반영. 도메인 상태를 해석하지 않고 그대로 사용. `startLoading()` / `stopLoading()`은 컨트롤러가 호출하고, `render()`는 데이터 표시만 담당. +- `src/eventListeners.ts` — DOM 이벤트 감지 및 keyword 추출. 유저 입력 검증(빈 keyword 필터)도 여기서 처리. +- `src/initTemplate.ts` — 앱 초기화 HTML 주입. App에서 최초 1회 호출. +- `src/App.ts` — 부팅만 담당. `initTemplate → initSearchSubmit → initLoadMore → loadPopular` 순서로 초기화. + +## 핵심 설계 규칙 + +- **레이어 위반 금지**: 도메인이 DOM을 참조하거나, 렌더러가 fetch를 참조하면 안 된다. +- **도메인은 UI 결정을 내린다**: "배너를 보여야 하는가"(`showsBanner`), "섹션 제목이 무엇인가"(`sectionTitle`) 등은 렌더러가 판단하지 않고 도메인 getter에서 반환한다. +- **렌더러는 상태를 해석하지 않는다**: `render(state, movies)`는 `state.canLoadMore` 등을 그대로 사용하며, 모드를 직접 비교하는 분기(`if mode === 'search'`)를 가지면 안 된다. +- **FetchStrategy 교체로 모드 전환**: `search()`는 `#strategy`를 `searchStrategy`로 교체하고, `loadPopular()`는 `popularStrategy`를 유지한다. 컨트롤러 내부에 fetch 분기(`if mode`)가 없어야 한다. diff --git a/README.md b/README.md index 8e1a7769f..f05bea7d2 100644 --- a/README.md +++ b/README.md @@ -26,3 +26,32 @@ FE 레벨1 영화 리뷰 미션 - [x] 배너 DOM 제거 및 추가 - [x] 결과 없음 처리: 검색 결과가 존재하지 않을 경우 "검색 결과가 없습니다"라는 안내 메시지를 출력합니다. + +## step2 + +1. 영화 상세 정보 조회 + +- [x] 영화 포스터 클릭 시 모달 창을 통해 상세 정보를 표시합니다. +- [x] API에서 제공하는 항목(제목, 포스터, 평점, 개봉연도, 장르, 줄거리)을 활용하여 상세 정보를 보여줍니다. +- [x] ESC 키를 누르거나 모달 외부 영역 또는 닫기 버튼 클릭 시 모달을 닫을 수 있습니다. + +2. 별점 매기기 + +- [x] 사용자는 영화에 대해 별점을 줄 수 있습니다. +- [x] 새로고침 후에도 사용자가 남긴 별점은 유지됩니다. (localStorage 사용) +- [x] 별점은 5개로 구성되며 한 개당 2점입니다. (1점 단위 미지원) + - 2점: 최악이예요 / 4점: 별로예요 / 6점: 보통이에요 / 8점: 재미있어요 / 10점: 명작이에요 + +3. 무한 스크롤 만들기 + +- [x] 더보기 버튼 삭제 +- [x] 사용자가 스크롤 끝에 도달하면 다시 로딩 + +3. UI/UX 개선 + +- [x] Figma 시안을 기준으로 UI를 구현합니다. + +4 E2E 구현 + +- [x] 무한스크롤 테스트 +- [x] 자세히 보기 테스트 diff --git a/cypress/e2e/spec.cy.ts b/cypress/e2e/spec.cy.ts index c419b7e00..5902b8fd4 100644 --- a/cypress/e2e/spec.cy.ts +++ b/cypress/e2e/spec.cy.ts @@ -1,86 +1,192 @@ +const visitHome = () => { + cy.visit("localhost:5173"); +}; + +const waitPopularLoaded = () => { + cy.wait("@getMovies"); +}; + +const searchMovie = (keyword: string, alias = "@searchMovies") => { + waitPopularLoaded(); + cy.get(".search-input").clear().type(keyword); + cy.get(".search-button").click(); + cy.wait(alias); +}; + +const searchMovieByEnter = (keyword: string, alias = "@searchMovies") => { + waitPopularLoaded(); + cy.get(".search-input").clear().type(`${keyword}{enter}`); + cy.wait(alias); +}; + +const openFirstMovie = () => { + cy.get(".thumbnail-list li").first().click(); +}; + +const openFirstPopularMovie = () => { + waitPopularLoaded(); + openFirstMovie(); +}; + +const openFirstSearchedMovie = (keyword = "Harry Potter") => { + searchMovie(keyword); + openFirstMovie(); +}; + +const closeModalByButton = () => { + cy.get("#closeModal").click(); +}; + +const closeModalByEsc = () => { + cy.get("body").type("{esc}"); +}; + +const ratePerfect = () => { + cy.get("#rate-stars img").last().click(); +}; + describe("인기영화 렌더링 테스트", () => { beforeEach(() => { - cy.intercept("GET", "**/movie/popular*").as("getMovies"); - cy.visit("localhost:5173"); + cy.intercept("GET", "**/movie/popular*", + { fixture: "movies.json" }, + ).as("getMovies"); + cy.intercept("GET", "**/movie/popular*page=2*", + { fixture: "movies2.json"} + ).as("getMoviesPage2"); + cy.intercept("GET", "**/movie/popular*page=3*" , + {fixture : "movies3.json"} + ).as("getMoviesPage3") + cy.intercept("GET", "**/movie/1*", { fixture: "movieDetail.json" }).as("getDetail"); + visitHome(); }); - it("웹에 접근을 하면 인기 영화 20개를 랜더링 한다", () => { - cy.wait("@getMovies"); + it("웹에 접근을 하면 인기 영화 20개가 보인다", () => { + waitPopularLoaded(); cy.get(".thumbnail-list li").should("have.length", 20); }); - it("더보기 버튼을 누르면 20개를 추가로 렌더링 한다", () => { - cy.wait("@getMovies"); - cy.get("#load-movie-button").click(); - cy.wait("@getMovies"); + it("스크롤을 끝까지 내렸을때 추가로 랜더링 한다", () => { + waitPopularLoaded(); + cy.scrollTo("bottom"); + cy.wait("@getMoviesPage2"); cy.get(".thumbnail-list li").should("have.length", 40); }); -}); -describe("인기 영화 더보기 버튼이 숨겨지는지 테스트", () => { - beforeEach(() => { - cy.intercept( - "GET", - "https://api.themoviedb.org/3/movie/popular?language=en-US&page=1", - { fixture: "movies.json" }, - ).as("getMovies"); + it("무한 스크롤로 페이지를 순차적으로 불러온다", () => { + waitPopularLoaded(); + cy.get(".thumbnail-list li").should("have.length", 20); - cy.intercept( - "GET", - "https://api.themoviedb.org/3/movie/popular?language=en-US&page=2", - { fixture: "movies2.json" }, - ).as("getMoviesPage2"); + cy.scrollTo("bottom"); + cy.wait("@getMoviesPage2"); + cy.get(".thumbnail-list li").should("have.length", 40); - cy.visit("http://localhost:5173"); + cy.scrollTo("bottom"); + cy.wait("@getMoviesPage3"); + cy.get(".thumbnail-list li").should("have.length", 60); }); - it("마지막 페이지 도달 시 더보기 버튼이 사라진다", () => { - cy.wait("@getMovies"); - cy.get("#load-movie-button").click(); - cy.wait("@getMoviesPage2"); - cy.get("#load-movie-button").should("have.css", "display", "none"); + it("영화 클릭시 모달창 열림", () =>{ + openFirstPopularMovie(); + cy.get(".modal-background").should("have.class", "active"); + }); + + it("ESC를 누를시 모달창 닫힘",() =>{ + openFirstPopularMovie(); + cy.get(".modal-background").should("have.class", "active"); + closeModalByEsc(); + cy.get(".modal-background").should("not.have.class", "active"); + }); + + it("닫기 버튼 클릭시 모달창 닫힘", () => { + openFirstPopularMovie(); + cy.get(".modal-background").should("have.class", "active"); + closeModalByButton(); + cy.get(".modal-background").should("not.have.class", "active"); + }); + + it("별점 클릭시 랜더링 하기", () =>{ + openFirstPopularMovie(); + ratePerfect(); + cy.get("#rate-evaluate").should("have.text", "명작이에요"); + cy.get("#rate-score").should("have.text", "(10/10)"); + }); + + it("별점이 모달을 닫고 다시 열어도 유지된다", () => { + openFirstPopularMovie(); + ratePerfect(); + closeModalByButton(); + openFirstMovie(); + cy.get("#rate-evaluate").should("have.text", "명작이에요"); + cy.get("#rate-score").should("have.text", "(10/10)"); }); }); describe("검색영화 렌더링 테스트", () => { beforeEach(() => { - cy.visit("localhost:5173"); + cy.intercept("GET", "**/search/movie*", { fixture: "movies.json" }).as("searchMovies"); + cy.intercept("GET", "**/movie/popular*", { fixture: "movies.json" }).as("getMovies"); + cy.intercept("GET", "**/movie/1*", { fixture: "movieDetail.json" }).as("getDetail"); + visitHome(); }); it("Harry Potter를 검색 하면 검색에 따른 영화를 랜더링 한다.", () => { - cy.get(".search-input").type("Harry Potter"); - cy.get(".search-button").click(); - cy.get(".thumbnail-list li").should("have.length.at.least", 1); + searchMovie("Harry Potter"); + cy.get(".thumbnail-list li").should("have.length", 20); }); it("뷁뷁뷁을 검색 하면 검색 결과가 없어야 한다.", () => { - cy.get(".search-input").type("뷁뷁뷁"); - cy.get(".search-input").type("{enter}"); + cy.intercept("GET", "**/search/movie*", { body: { results: [], total_pages: 1 } }).as("searchEmpty"); + searchMovieByEnter("뷁뷁뷁", "@searchEmpty"); cy.get(".thumbnail-list li").should("have.length", 0); cy.get("#no-result").contains("검색 결과가 없습니다.").should("exist"); }); -}); -describe("검색 영화 더보기 버튼이 숨겨지는지 테스트", () => { - beforeEach(() => { - cy.intercept("GET", "**/search/movie*page=1*", { - fixture: "movies.json", - }).as("getMovies"); + it("영화 클릭시 모달창 열림", () => { + openFirstSearchedMovie(); + cy.get(".modal-background").should("have.class", "active"); + }); + + it("닫기 버튼 클릭시 모달창 닫힘", () => { + openFirstSearchedMovie(); + closeModalByButton(); + cy.get(".modal-background").should("not.have.class", "active"); + }); - cy.intercept("GET", "**/search/movie*page=2*", { - fixture: "movies2.json", - }).as("getMoviesPage2"); + it("ESC를 누를시 모달창 닫힘", () => { + openFirstSearchedMovie(); + closeModalByEsc(); + cy.get(".modal-background").should("not.have.class", "active"); + }); - cy.visit("http://localhost:5173"); + it("별점 클릭시 랜더링 하기", () => { + openFirstSearchedMovie(); + ratePerfect(); + cy.get("#rate-evaluate").should("have.text", "명작이에요"); + cy.get("#rate-score").should("have.text", "(10/10)"); }); - it("마지막 페이지 도달 시 더보기 버튼이 사라진다", () => { - cy.get(".search-input").type("영화"); - cy.get(".search-input").type("{enter}"); - cy.wait("@getMovies"); - cy.get("#load-movie-button").click(); - cy.wait("@getMoviesPage2"); - cy.get("#load-movie-button").should("have.css", "display", "none"); + it("별점이 모달을 닫고 다시 열어도 유지된다", () => { + openFirstSearchedMovie(); + ratePerfect(); + closeModalByButton(); + openFirstMovie(); + cy.get("#rate-evaluate").should("have.text", "명작이에요"); + cy.get("#rate-score").should("have.text", "(10/10)"); + }); + + it("연결이 되어 있지 않았을때 에러 표시를 한다", () => { + cy.intercept("GET", "**/search/movie*", { forceNetworkError: true }).as("searchError"); + + const alertStub = cy.stub(); + cy.on("window:alert", alertStub); + + waitPopularLoaded(); + cy.get(".search-input").clear().type("Harry Potter"); + cy.get(".search-button").click(); + + cy.wait("@searchError").then(() => { + expect(alertStub).to.have.been.called; + }); }); }); diff --git a/cypress/fixtures/movieDetail.json b/cypress/fixtures/movieDetail.json new file mode 100644 index 000000000..1b7a9ac59 --- /dev/null +++ b/cypress/fixtures/movieDetail.json @@ -0,0 +1,13 @@ +{ + "id": 1, + "title": "인사이드 아웃 2", + "poster_path": "/test0.jpg", + "vote_average": 7.8, + "overview": "라이리의 성장과 새로운 감정들의 이야기.", + "release_date": "2024-06-14", + "genres": [ + { "id": 16, "name": "애니메이션" }, + { "id": 35, "name": "코미디" }, + { "id": 18, "name": "드라마" } + ] +} diff --git a/cypress/fixtures/movies.json b/cypress/fixtures/movies.json index cbd429c1c..1999ec65a 100644 --- a/cypress/fixtures/movies.json +++ b/cypress/fixtures/movies.json @@ -2,125 +2,165 @@ "page": 1, "results": [ { - "title": "영화 1", - "vote_average": 1.0, + "id": 1, + "title": "인사이드 아웃 2", + "vote_average": 7.8, "poster_path": "/test0.jpg", - "backdrop_path": "/backdrop0.jpg" + "backdrop_path": "/backdrop0.jpg", + "overview": "라이리의 성장과 새로운 감정들의 이야기." }, { - "title": "영화 2", - "vote_average": 2.0, + "id": 2, + "title": "듄: 파트 2", + "vote_average": 8.2, "poster_path": "/test1.jpg", - "backdrop_path": "/backdrop1.jpg" + "backdrop_path": "/backdrop1.jpg", + "overview": "폴 아트레이데스가 프레멘과 함께 싸운다." }, { - "title": "영화 3", - "vote_average": 3.0, + "id": 3, + "title": "쿵푸팬더 4", + "vote_average": 6.9, "poster_path": "/test2.jpg", - "backdrop_path": "/backdrop2.jpg" + "backdrop_path": "/backdrop2.jpg", + "overview": "포가 새로운 영웅을 찾아 나선다." }, { - "title": "영화 4", - "vote_average": 4.0, + "id": 4, + "title": "고질라 x 콩", + "vote_average": 7.1, "poster_path": "/test3.jpg", - "backdrop_path": "/backdrop3.jpg" + "backdrop_path": "/backdrop3.jpg", + "overview": "두 타이탄이 새로운 위협에 맞선다." }, { - "title": "영화 5", - "vote_average": 5.0, + "id": 5, + "title": "데드풀과 울버린", + "vote_average": 7.9, "poster_path": "/test4.jpg", - "backdrop_path": "/backdrop4.jpg" + "backdrop_path": "/backdrop4.jpg", + "overview": "데드풀과 울버린의 유쾌한 어드벤처." }, { - "title": "영화 6", - "vote_average": 6.0, + "id": 6, + "title": "에이리언: 로물루스", + "vote_average": 7.2, "poster_path": "/test5.jpg", - "backdrop_path": "/backdrop5.jpg" + "backdrop_path": "/backdrop5.jpg", + "overview": "우주 정거장에서 생존을 위한 사투." }, { - "title": "영화 7", - "vote_average": 7.0, + "id": 7, + "title": "트위스터즈", + "vote_average": 6.8, "poster_path": "/test6.jpg", - "backdrop_path": "/backdrop6.jpg" + "backdrop_path": "/backdrop6.jpg", + "overview": "폭풍 추적자들의 이야기." }, { - "title": "영화 8", - "vote_average": 8.0, + "id": 8, + "title": "비틀쥬스 비틀쥬스", + "vote_average": 7.0, "poster_path": "/test7.jpg", - "backdrop_path": "/backdrop7.jpg" + "backdrop_path": "/backdrop7.jpg", + "overview": "비틀쥬스가 돌아왔다." }, { - "title": "영화 9", - "vote_average": 9.0, + "id": 9, + "title": "조커: 폴리 아 되", + "vote_average": 5.5, "poster_path": "/test8.jpg", - "backdrop_path": "/backdrop8.jpg" + "backdrop_path": "/backdrop8.jpg", + "overview": "조커와 할리 퀸의 이야기." }, { - "title": "영화 10", - "vote_average": 10.0, + "id": 10, + "title": "글래디에이터 2", + "vote_average": 7.3, "poster_path": "/test9.jpg", - "backdrop_path": "/backdrop9.jpg" + "backdrop_path": "/backdrop9.jpg", + "overview": "로마 검투사의 귀환." }, { - "title": "영화 11", - "vote_average": 1.0, + "id": 11, + "title": "베놈: 더 라스트 댄스", + "vote_average": 6.5, "poster_path": "/test10.jpg", - "backdrop_path": "/backdrop10.jpg" + "backdrop_path": "/backdrop10.jpg", + "overview": "베놈의 마지막 여정." }, { - "title": "영화 12", - "vote_average": 2.0, + "id": 12, + "title": "모아나 2", + "vote_average": 7.0, "poster_path": "/test11.jpg", - "backdrop_path": "/backdrop11.jpg" + "backdrop_path": "/backdrop11.jpg", + "overview": "모아나의 새로운 바다 모험." }, { - "title": "영화 13", - "vote_average": 3.0, + "id": 13, + "title": "위키드", + "vote_average": 7.6, "poster_path": "/test12.jpg", - "backdrop_path": "/backdrop12.jpg" + "backdrop_path": "/backdrop12.jpg", + "overview": "오즈의 마법사 이전 이야기." }, { - "title": "영화 14", - "vote_average": 4.0, + "id": 14, + "title": "레드 원", + "vote_average": 6.4, "poster_path": "/test13.jpg", - "backdrop_path": "/backdrop13.jpg" + "backdrop_path": "/backdrop13.jpg", + "overview": "산타클로스를 구출하라." }, { - "title": "영화 15", - "vote_average": 5.0, + "id": 15, + "title": "서브스턴스", + "vote_average": 7.4, "poster_path": "/test14.jpg", - "backdrop_path": "/backdrop14.jpg" + "backdrop_path": "/backdrop14.jpg", + "overview": "충격적인 자아 분열의 이야기." }, { - "title": "영화 16", - "vote_average": 6.0, + "id": 16, + "title": "컨클라베", + "vote_average": 7.5, "poster_path": "/test15.jpg", - "backdrop_path": "/backdrop15.jpg" + "backdrop_path": "/backdrop15.jpg", + "overview": "교황 선출의 비밀." }, { - "title": "영화 17", - "vote_average": 7.0, + "id": 17, + "title": "아쿠아맨 2", + "vote_average": 5.9, "poster_path": "/test16.jpg", - "backdrop_path": "/backdrop16.jpg" + "backdrop_path": "/backdrop16.jpg", + "overview": "아쿠아맨의 새로운 모험." }, { - "title": "영화 18", - "vote_average": 8.0, + "id": 18, + "title": "마이그레이션", + "vote_average": 7.1, "poster_path": "/test17.jpg", - "backdrop_path": "/backdrop17.jpg" + "backdrop_path": "/backdrop17.jpg", + "overview": "오리 가족의 대이동." }, { - "title": "영화 19", - "vote_average": 9.0, + "id": 19, + "title": "원피스 필름 레드", + "vote_average": 8.0, "poster_path": "/test18.jpg", - "backdrop_path": "/backdrop18.jpg" + "backdrop_path": "/backdrop18.jpg", + "overview": "루피와 샹크스의 이야기." }, { - "title": "영화 20", - "vote_average": 9.0, - "poster_path": "/test18.jpg", - "backdrop_path": "/backdrop18.jpg" + "id": 20, + "title": "가디언즈 오브 갤럭시 3", + "vote_average": 8.0, + "poster_path": "/test19.jpg", + "backdrop_path": "/backdrop19.jpg", + "overview": "가디언즈의 마지막 이야기." } ], - "total_pages": 2 + "total_pages": 3 } diff --git a/cypress/fixtures/movies2.json b/cypress/fixtures/movies2.json index 3cfaf3484..e04d5e6b4 100644 --- a/cypress/fixtures/movies2.json +++ b/cypress/fixtures/movies2.json @@ -2,131 +2,165 @@ "page": 2, "results": [ { - "title": "영화 20", - "vote_average": 10.0, - "poster_path": "/test19.jpg", - "backdrop_path": "/backdrop19.jpg" - }, - { - "title": "영화 21", - "vote_average": 1.0, + "id": 21, + "title": "패스트 라이브즈", + "vote_average": 7.9, "poster_path": "/test20.jpg", - "backdrop_path": "/backdrop20.jpg" + "backdrop_path": "/backdrop20.jpg", + "overview": "두 연인의 엇갈린 운명." }, { - "title": "영화 22", - "vote_average": 2.0, + "id": 22, + "title": "오펜하이머", + "vote_average": 8.4, "poster_path": "/test21.jpg", - "backdrop_path": "/backdrop21.jpg" + "backdrop_path": "/backdrop21.jpg", + "overview": "원자폭탄의 아버지 이야기." }, { - "title": "영화 23", - "vote_average": 3.0, + "id": 23, + "title": "바비", + "vote_average": 7.0, "poster_path": "/test22.jpg", - "backdrop_path": "/backdrop22.jpg" + "backdrop_path": "/backdrop22.jpg", + "overview": "바비랜드에서 현실 세계로." }, { - "title": "영화 24", - "vote_average": 4.0, + "id": 24, + "title": "미션 임파서블 7", + "vote_average": 7.6, "poster_path": "/test23.jpg", - "backdrop_path": "/backdrop23.jpg" + "backdrop_path": "/backdrop23.jpg", + "overview": "에단 헌트의 새로운 임무." }, { - "title": "영화 25", - "vote_average": 5.0, + "id": 25, + "title": "가디언즈 오브 갤럭시 홀리데이 스페셜", + "vote_average": 7.2, "poster_path": "/test24.jpg", - "backdrop_path": "/backdrop24.jpg" + "backdrop_path": "/backdrop24.jpg", + "overview": "가디언즈의 크리스마스 특집." }, { - "title": "영화 26", - "vote_average": 6.0, + "id": 26, + "title": "인디아나 존스 5", + "vote_average": 6.5, "poster_path": "/test25.jpg", - "backdrop_path": "/backdrop25.jpg" + "backdrop_path": "/backdrop25.jpg", + "overview": "인디아나 존스의 마지막 모험." }, { - "title": "영화 27", - "vote_average": 7.0, + "id": 27, + "title": "앤트맨과 와스프: 퀀텀매니아", + "vote_average": 6.1, "poster_path": "/test26.jpg", - "backdrop_path": "/backdrop26.jpg" + "backdrop_path": "/backdrop26.jpg", + "overview": "양자 세계로의 여행." }, { - "title": "영화 28", - "vote_average": 8.0, + "id": 28, + "title": "스파이더맨: 어크로스 더 스파이더버스", + "vote_average": 8.6, "poster_path": "/test27.jpg", - "backdrop_path": "/backdrop27.jpg" + "backdrop_path": "/backdrop27.jpg", + "overview": "마일스 모랄레스의 멀티버스 모험." }, { - "title": "영화 29", - "vote_average": 9.0, + "id": 29, + "title": "엘리멘탈", + "vote_average": 7.2, "poster_path": "/test28.jpg", - "backdrop_path": "/backdrop28.jpg" + "backdrop_path": "/backdrop28.jpg", + "overview": "불과 물의 사랑 이야기." }, { - "title": "영화 30", - "vote_average": 10.0, + "id": 30, + "title": "인어공주", + "vote_average": 6.8, "poster_path": "/test29.jpg", - "backdrop_path": "/backdrop29.jpg" + "backdrop_path": "/backdrop29.jpg", + "overview": "아리엘의 실사판 이야기." }, { - "title": "영화 31", - "vote_average": 1.0, + "id": 31, + "title": "분노의 질주: 라이드 오어 다이", + "vote_average": 7.3, "poster_path": "/test30.jpg", - "backdrop_path": "/backdrop30.jpg" + "backdrop_path": "/backdrop30.jpg", + "overview": "도미닉 토레토의 귀환." }, { - "title": "영화 32", - "vote_average": 2.0, + "id": 32, + "title": "가디언즈 오브 갤럭시 3", + "vote_average": 8.0, "poster_path": "/test31.jpg", - "backdrop_path": "/backdrop31.jpg" + "backdrop_path": "/backdrop31.jpg", + "overview": "로켓의 기원이 밝혀진다." }, { - "title": "영화 33", - "vote_average": 3.0, + "id": 33, + "title": "닥터 스트레인지: 대혼돈의 멀티버스", + "vote_average": 6.9, "poster_path": "/test32.jpg", - "backdrop_path": "/backdrop32.jpg" + "backdrop_path": "/backdrop32.jpg", + "overview": "닥터 스트레인지의 멀티버스 탐험." }, { - "title": "영화 34", - "vote_average": 4.0, + "id": 34, + "title": "더 배트맨", + "vote_average": 7.8, "poster_path": "/test33.jpg", - "backdrop_path": "/backdrop33.jpg" + "backdrop_path": "/backdrop33.jpg", + "overview": "배트맨의 어두운 탐정 이야기." }, { - "title": "영화 35", - "vote_average": 5.0, + "id": 35, + "title": "탑건: 매버릭", + "vote_average": 8.3, "poster_path": "/test34.jpg", - "backdrop_path": "/backdrop34.jpg" + "backdrop_path": "/backdrop34.jpg", + "overview": "매버릭의 귀환과 새로운 임무." }, { - "title": "영화 36", - "vote_average": 6.0, + "id": 36, + "title": "아바타: 물의 길", + "vote_average": 7.6, "poster_path": "/test35.jpg", - "backdrop_path": "/backdrop35.jpg" + "backdrop_path": "/backdrop35.jpg", + "overview": "판도라의 바다를 탐험하다." }, { - "title": "영화 37", - "vote_average": 7.0, + "id": 37, + "title": "블랙 팬서: 와칸다 포에버", + "vote_average": 7.3, "poster_path": "/test36.jpg", - "backdrop_path": "/backdrop36.jpg" + "backdrop_path": "/backdrop36.jpg", + "overview": "와칸다의 새로운 수호자." }, { - "title": "영화 38", - "vote_average": 8.0, + "id": 38, + "title": "소닉 더 무비 2", + "vote_average": 7.0, "poster_path": "/test37.jpg", - "backdrop_path": "/backdrop37.jpg" + "backdrop_path": "/backdrop37.jpg", + "overview": "소닉과 테일즈의 모험." }, { - "title": "영화 39", - "vote_average": 9.0, + "id": 39, + "title": "닥터: 나를 찾아서", + "vote_average": 7.1, "poster_path": "/test38.jpg", - "backdrop_path": "/backdrop38.jpg" + "backdrop_path": "/backdrop38.jpg", + "overview": "자아를 찾아 떠나는 여정." }, { - "title": "영화 40", - "vote_average": 10.0, + "id": 40, + "title": "에브리씽 에브리웨어 올 앳 원스", + "vote_average": 8.0, "poster_path": "/test39.jpg", - "backdrop_path": "/backdrop39.jpg" + "backdrop_path": "/backdrop39.jpg", + "overview": "멀티버스를 넘나드는 세탁소 주인." } ], - "total_pages": 2 + "total_pages": 3 } diff --git a/cypress/fixtures/movies3.json b/cypress/fixtures/movies3.json new file mode 100644 index 000000000..808b95849 --- /dev/null +++ b/cypress/fixtures/movies3.json @@ -0,0 +1,166 @@ +{ + "page": 3, + "results": [ + { + "id": 41, + "title": "그래비티", + "vote_average": 7.7, + "poster_path": "/test40.jpg", + "backdrop_path": "/backdrop40.jpg", + "overview": "우주에서 살아남기 위한 사투." + }, + { + "id": 42, + "title": "인터스텔라", + "vote_average": 8.5, + "poster_path": "/test41.jpg", + "backdrop_path": "/backdrop41.jpg", + "overview": "인류의 미래를 찾는 우주 여정." + }, + { + "id": 43, + "title": "매드맥스: 분노의 도로", + "vote_average": 8.1, + "poster_path": "/test42.jpg", + "backdrop_path": "/backdrop42.jpg", + "overview": "광기의 사막에서 벌어지는 추격전." + }, + { + "id": 44, + "title": "테넷", + "vote_average": 7.3, + "poster_path": "/test43.jpg", + "backdrop_path": "/backdrop43.jpg", + "overview": "시간 역행 전쟁을 막기 위한 작전." + }, + { + "id": 45, + "title": "블레이드 러너 2049", + "vote_average": 8.0, + "poster_path": "/test44.jpg", + "backdrop_path": "/backdrop44.jpg", + "overview": "복제인간의 비밀을 쫓는 이야기." + }, + { + "id": 46, + "title": "1917", + "vote_average": 8.2, + "poster_path": "/test45.jpg", + "backdrop_path": "/backdrop45.jpg", + "overview": "전장의 한복판을 가로지르는 임무." + }, + { + "id": 47, + "title": "라라랜드", + "vote_average": 8.0, + "poster_path": "/test46.jpg", + "backdrop_path": "/backdrop46.jpg", + "overview": "꿈과 사랑 사이의 선택." + }, + { + "id": 48, + "title": "기생충", + "vote_average": 8.5, + "poster_path": "/test47.jpg", + "backdrop_path": "/backdrop47.jpg", + "overview": "두 가족의 예기치 않은 만남." + }, + { + "id": 49, + "title": "조조 래빗", + "vote_average": 7.9, + "poster_path": "/test48.jpg", + "backdrop_path": "/backdrop48.jpg", + "overview": "전쟁 속 아이의 성장기." + }, + { + "id": 50, + "title": "듄", + "vote_average": 8.0, + "poster_path": "/test49.jpg", + "backdrop_path": "/backdrop49.jpg", + "overview": "사막 행성 아라키스의 운명." + }, + { + "id": 51, + "title": "노 웨이 홈", + "vote_average": 8.2, + "poster_path": "/test50.jpg", + "backdrop_path": "/backdrop50.jpg", + "overview": "멀티버스가 열린 스파이더맨의 위기." + }, + { + "id": 52, + "title": "메멘토", + "vote_average": 8.4, + "poster_path": "/test51.jpg", + "backdrop_path": "/backdrop51.jpg", + "overview": "기억을 잃은 남자의 복수극." + }, + { + "id": 53, + "title": "나이브스 아웃", + "vote_average": 7.9, + "poster_path": "/test52.jpg", + "backdrop_path": "/backdrop52.jpg", + "overview": "기상천외한 저택 살인 미스터리." + }, + { + "id": 54, + "title": "바스터즈: 거친 녀석들", + "vote_average": 8.3, + "poster_path": "/test53.jpg", + "backdrop_path": "/backdrop53.jpg", + "overview": "전쟁을 뒤집는 특수부대 이야기." + }, + { + "id": 55, + "title": "셜록 홈즈", + "vote_average": 7.6, + "poster_path": "/test54.jpg", + "backdrop_path": "/backdrop54.jpg", + "overview": "명탐정의 새로운 사건 수사." + }, + { + "id": 56, + "title": "존 윅 4", + "vote_average": 7.8, + "poster_path": "/test55.jpg", + "backdrop_path": "/backdrop55.jpg", + "overview": "끝나지 않은 복수의 총성." + }, + { + "id": 57, + "title": "탐정 홍길동", + "vote_average": 6.9, + "poster_path": "/test56.jpg", + "backdrop_path": "/backdrop56.jpg", + "overview": "괴짜 탐정의 마지막 추적." + }, + { + "id": 58, + "title": "어라이벌", + "vote_average": 7.9, + "poster_path": "/test57.jpg", + "backdrop_path": "/backdrop57.jpg", + "overview": "외계 생명체와의 첫 접촉." + }, + { + "id": 59, + "title": "소울", + "vote_average": 8.1, + "poster_path": "/test58.jpg", + "backdrop_path": "/backdrop58.jpg", + "overview": "삶의 의미를 찾아 떠나는 여정." + }, + { + "id": 60, + "title": "업", + "vote_average": 8.3, + "poster_path": "/test59.jpg", + "backdrop_path": "/backdrop59.jpg", + "overview": "풍선 집으로 떠나는 모험." + } + ], + "total_pages": 3 +} diff --git a/public/styles/main.css b/public/styles/main.css index 82e33d88a..f928fece1 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -13,6 +13,13 @@ a { body { font-size: 16px; color: var(--color-white); + background-color: #000; +} + +#app { + max-width: 1120px; + width: 100%; + margin: 0 auto; background-color: #242A32; } @@ -56,7 +63,6 @@ button.primary { } #wrap { - min-width: 1440px; min-height: 100vh; background-color: #242A32; display: flex; @@ -80,8 +86,10 @@ button.primary { } .container { - max-width: 1280px; + max-width: 1120px; + width: 100%; margin: 0 auto; + padding: 0 55px; flex: 1; } @@ -105,7 +113,7 @@ button.primary { } .top-rated-movie { - margin-top: 64px; + margin-top: 160px; } .top-rated-movie > *:not(:last-child) { @@ -127,17 +135,18 @@ h1.logo { position: relative; z-index: 3; display: flex; + flex-direction: column; align-items: center; - padding: 48px; + justify-content: center; + padding: 32px 48px; align-self: start; + width: 100%; + gap: 16px; } .header-top .search-bar { - position: absolute; - left: 50%; - transform: translateX(-50%); max-width: 700px; - width: 50%; + width: 60%; } .background-container { @@ -252,3 +261,25 @@ footer.footer p:not(:last-child) { letter-spacing: 0 !important; margin-top: 1rem; } + +@media (max-width: 480px) { + .background-container { + padding: 24px; + } + + .header-top { + padding: 24px; + } + + .header-top .search-bar { + width: 100%; + } + + .top-rated-container { + max-width: 100%; + } + + .container { + padding: 0 24px; + } +} diff --git a/public/styles/modal.css b/public/styles/modal.css index 240a7a37c..5d3a07e51 100644 --- a/public/styles/modal.css +++ b/public/styles/modal.css @@ -86,3 +86,86 @@ body.modal-open { max-height: 430px; overflow-y: auto; } + +#customRate { + display: flex; + align-items: center; + gap: 8px; +} + +#rate-stars { + display: flex; + gap: 8px; +} + +#rate-stars img { + width: 24px; + height: 24px; +} + +@media (max-width: 480px) { + .modal-background { + align-items: flex-end; + } + + .modal { + width: 100%; + border-radius: 16px 16px 0 0; + margin: 0; + position: fixed; + bottom: 0; + left: 0; + max-height: 80vh; + overflow-y: auto; + } + + .modal-container { + flex-direction: column; + align-items: center; + } + + .modal-image { + display: none; + } + + .modal-description { + margin-left: 0; + margin-top: 0; + text-align: center; + } + + .modal-description .rate { + justify-content: center; + } + + #customRate { + flex-direction: column; + align-items: center; + } +} + +@media (max-width: 1024px) { + .modal-background { + align-items: flex-end; + } + + .modal { + width: 100%; + border-radius: 16px 16px 0 0; + margin: 0; + } + + .modal-container { + flex-direction: column; + align-items: center; + } + + .modal-image img { + width: 200px; + } + + .modal-description { + margin-left: 0; + margin-top: 16px; + } +} diff --git a/public/styles/thumbnail.css b/public/styles/thumbnail.css index 5a03fed02..a2859bf9e 100644 --- a/public/styles/thumbnail.css +++ b/public/styles/thumbnail.css @@ -1,10 +1,11 @@ @import "./colors.css"; .thumbnail-list { - margin: 0 auto 56px; + margin: 0 0 56px; display: grid; - grid-template-columns: repeat(5, 200px); + grid-template-columns: repeat(4, 200px); gap: 70px; + justify-content: center; } .thumbnail { @@ -81,3 +82,15 @@ p.rate > span { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } + +@media (max-width: 1024px) { + .thumbnail-list { + grid-template-columns: repeat(3, 200px); + } +} + +@media (max-width: 480px) { + .thumbnail-list { + grid-template-columns: repeat(1, 200px); + } +} diff --git a/src/App.ts b/src/App.ts index 528ffef6e..5c8b8fa1e 100644 --- a/src/App.ts +++ b/src/App.ts @@ -1,99 +1,18 @@ -import template from "../templates/index.html?raw"; -import { renderMovies, renderBanner, renderSearchedMovies } from "./movieRenderer.ts"; -import DOM from "./dom.ts"; -import { setupEventListeners } from "./eventListeners.ts"; -import PopularMovies from "./PopularMovies.ts"; -import SearchedMovies from "./SearchedMovies.ts"; - -type AppMode = "popular" | "search" | "loading"; +import { initTemplate } from './initTemplate.ts'; +import { initSearchSubmit, initLoadMore, initMovieClick, initModalClose, initRatingClick, initDetailClick } from './eventListeners.ts'; +import { loadPopular, search, loadMore } from './presentation/MovieController.ts'; +import { openModal, closeModal, rateMovie } from './presentation/ModalController.ts'; class App { - #popular = new PopularMovies(); - #searched = new SearchedMovies(); - #mode: AppMode = "popular"; - constructor() { - document.querySelector("#app")!.innerHTML = template; - this.#loadPopularMovies(); - setupEventListeners( - () => this.#handleSearchSubmit(), - () => this.#handleLoadMore(), - ); - } - - #loadPopularMovies = async () => { - this.#mode = "loading"; - try { - await this.#popular.fetch(); - renderBanner(this.#popular.movies[0]); - renderMovies(this.#popular.movies); - if (this.#popular.isLastPage) this.#hideLoadButton(); - } catch (error) { - alert(error instanceof Error ? error.message : "오류가 발생했습니다."); - } finally { - this.#mode = "popular"; - } - }; - - #handleSearchSubmit = async () => { - if (!DOM.searchInput || this.#mode === "loading") return; - - this.#mode = "loading"; - this.#searched.reset(); - - if (DOM.thumbnailList) DOM.thumbnailList.replaceChildren(); - if (DOM.loadMovieButton) DOM.loadMovieButton.style.display = ""; - if (DOM.backgroundContainer) DOM.backgroundContainer.hidden = true; - - try { - await this.#searched.fetch(DOM.searchInput.value); - renderSearchedMovies(this.#searched.movies); - if (this.#searched.isLastPage) this.#hideLoadButton(); - if (DOM.sectionTitle) { - DOM.sectionTitle.textContent = `"${DOM.searchInput.value}" 검색 결과`; - } - } catch (error) { - alert(error instanceof Error ? error.message : "오류가 발생했습니다."); - } finally { - this.#mode = "search"; - } - }; - - #handleLoadMore = () => { - if (this.#mode === "loading") return; - if (this.#mode === "popular") this.#loadMorePopular(); - else if (this.#mode === "search") this.#loadMoreSearched(); - }; - - #loadMorePopular = async () => { - this.#mode = "loading"; - try { - await this.#popular.loadMore(); - renderMovies(this.#popular.movies); - if (this.#popular.isLastPage) this.#hideLoadButton(); - } catch (error) { - alert(error instanceof Error ? error.message : "오류가 발생했습니다."); - } finally { - this.#mode = "popular"; - } - }; - - #loadMoreSearched = async () => { - if (!DOM.searchInput) return; - this.#mode = "loading"; - try { - await this.#searched.loadMore(DOM.searchInput.value); - renderSearchedMovies(this.#searched.movies); - if (this.#searched.isLastPage) this.#hideLoadButton(); - } catch (error) { - alert(error instanceof Error ? error.message : "오류가 발생했습니다."); - } finally { - this.#mode = "search"; - } - }; - - #hideLoadButton() { - if (DOM.loadMovieButton) DOM.loadMovieButton.style.display = "none"; + initTemplate(); + initDetailClick(openModal) + initSearchSubmit(search); + initLoadMore(loadMore); + initMovieClick(openModal); + initModalClose(closeModal); + initRatingClick(rateMovie) + loadPopular(); } } diff --git a/src/PopularMovies.ts b/src/PopularMovies.ts deleted file mode 100644 index 38e893769..000000000 --- a/src/PopularMovies.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { fetchMovies } from "./movieAPIResponse.ts"; -import type { Movie } from "../types/Movie.ts"; - -class PopularMovies { - #currentPage = 1; - #totalPages = 0; - #movies: Movie[] = []; - - async fetch() { - const data = await fetchMovies(this.#currentPage); - this.#movies = data.results; - this.#totalPages = data.total_pages; - } - - async loadMore() { - this.#currentPage += 1; - await this.fetch(); - } - - get movies() { - return this.#movies; - } - - get isLastPage() { - return this.#currentPage === this.#totalPages; - } -} - -export default PopularMovies; diff --git a/src/SearchedMovies.ts b/src/SearchedMovies.ts deleted file mode 100644 index b40dd03d1..000000000 --- a/src/SearchedMovies.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { fetchSearchedMovies } from "./movieAPIResponse.ts"; -import type { Movie } from "../types/Movie.ts"; - -class SearchedMovies { - #currentPage = 1; - #totalPages = 0; - #movies: Movie[] = []; - - async fetch(keyword: string) { - const data = await fetchSearchedMovies(keyword, this.#currentPage); - this.#movies = data.results; - this.#totalPages = data.total_pages; - } - - async loadMore(keyword: string) { - this.#currentPage += 1; - await this.fetch(keyword); - } - - get movies() { - return this.#movies; - } - - get isLastPage() { - return this.#currentPage === this.#totalPages; - } - - reset() { - this.#currentPage = 1; - } -} - -export default SearchedMovies; diff --git a/src/createHtml.ts b/src/createHtml.ts index 9a3436719..1a0e8bb4f 100644 --- a/src/createHtml.ts +++ b/src/createHtml.ts @@ -6,6 +6,7 @@ export const createMovieItemHTML = (movie: Movie): HTMLLIElement => { const posterSrc = `${posterBaseURL}${movie.poster_path}`; const li = document.createElement("li"); + li.dataset.movieId = String(movie.id); li.insertAdjacentHTML( "beforeend", /*html*/ ` @@ -36,11 +37,10 @@ export const createBannerHTML = (movie: Movie): HTMLDivElement => { ${movie.vote_average}
+ 평균
+