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}
${movie.title}
- + `, ); - - return div; + return div }; @@ -57,3 +57,38 @@ export const createNoResultHTML = (): HTMLDivElement => { return div; }; + +export const createModalHTML = (): HTMLDivElement => { + const div = document.createElement("div"); + div.insertAdjacentHTML( + "beforeend", + /*html*/ ``, + ); + return div; +}; diff --git a/src/dom.ts b/src/dom.ts deleted file mode 100644 index 504b05143..000000000 --- a/src/dom.ts +++ /dev/null @@ -1,22 +0,0 @@ -const DOM = { - get thumbnailList() { - return document.querySelector(".thumbnail-list"); - }, - get backgroundContainer() { - return document.querySelector(".background-container"); - }, - get loadMovieButton() { - return document.querySelector("#load-movie-button"); - }, - get sectionTitle() { - return document.querySelector("#section-title"); - }, - get banner() { - return document.querySelector(".top-rated-movie"); - }, - get searchInput() { - return document.querySelector(".search-input"); - }, -}; - -export default DOM; diff --git a/src/domain/MovieBrowser.ts b/src/domain/MovieBrowser.ts new file mode 100644 index 000000000..e8db6f8f1 --- /dev/null +++ b/src/domain/MovieBrowser.ts @@ -0,0 +1,52 @@ +type Mode = 'popular' | 'search'; + +export class MovieBrowser { + #mode: Mode = 'popular'; + #keyword = ''; + #page = 1; + #totalPages = 0; + + get isLastPage() { + return this.#totalPages > 0 && this.#page >= this.#totalPages; + } + + get canLoadMore() { + return this.#totalPages > 0 && !this.isLastPage; + } + + get isNewSession() { + return this.#page === 1; + } + + get showsBanner() { + return this.#mode === 'popular'; + } + + get sectionTitle() { + return this.#mode === 'search' ? `"${this.#keyword}" 검색 결과` : ''; + } + + get currentPage() { + return this.#page; + } + + get nextPageNumber() { + return this.#page + 1; + } + + startSearch(keyword: string) { + this.#mode = 'search'; + this.#keyword = keyword; + this.#page = 1; + this.#totalPages = 0; + } + + setTotalPages(totalPages: number) { + this.#totalPages = totalPages; + } + + nextPage() { + if (!this.canLoadMore) return; + this.#page += 1; + } +} diff --git a/src/domain/MovieSelection.ts b/src/domain/MovieSelection.ts new file mode 100644 index 000000000..cc29394c2 --- /dev/null +++ b/src/domain/MovieSelection.ts @@ -0,0 +1,18 @@ +import type { MovieDetail } from '../../types/MovieDetail.ts'; + +export class MovieSelection { + #selected: MovieDetail | null = null; + #rating: number | null = null; + + get selected() { return this.#selected; } + get isOpen() { return this.#selected !== null; } + get rating() { return this.#rating; } + + select(detail: MovieDetail, savedRating: number | null) { + this.#selected = detail; + this.#rating = savedRating; + } + + rate(rating: number) { this.#rating = rating; } + close() { this.#selected = null; this.#rating = null; } +} diff --git a/src/eventListeners.ts b/src/eventListeners.ts index e611378bb..f16cccf08 100644 --- a/src/eventListeners.ts +++ b/src/eventListeners.ts @@ -1,26 +1,66 @@ -import DOM from "./dom.ts"; +const getKeyword = () => + document.querySelector('.search-input')?.value.trim() ?? ''; -export const setupEventListeners = ( - onSearchSubmit: () => void, - onLoadMore: () => void, -) => { - const loadMovieButton = DOM.loadMovieButton; - if (!loadMovieButton) return; +export const initSearchSubmit = (onSubmit: (keyword: string) => void) => { + document.addEventListener('click', (e: MouseEvent) => { + if ((e.target as HTMLElement).closest('.search-button')) { + const keyword = getKeyword(); + if (keyword) onSubmit(keyword); + } + }); - document.addEventListener("click", (e: MouseEvent) => { - if ((e.target as HTMLElement).closest(".search-button")) { - onSearchSubmit(); + document.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter' && (e.target as HTMLElement).closest('.search-input')) { + const keyword = getKeyword(); + if (keyword) onSubmit(keyword); } }); +}; + +export const initDetailClick = (onClick: (movieId: number) => void) => { + document.addEventListener('click', (e: MouseEvent) => { + const target = (e.target as HTMLElement).closest('.primary.detail'); + if (!target) return; + const movieId = (target as HTMLElement).dataset.movieId; + if (!movieId) return; + onClick(Number(movieId)); + }); +}; - document.addEventListener("keydown", (e: KeyboardEvent) => { - if ( - e.key === "Enter" && - (e.target as HTMLElement).closest(".search-input") - ) { - onSearchSubmit(); +export const initLoadMore = (onLoadMore: () => void) => { + const THRESHOLD = 100; + window.addEventListener('scroll', () => { + if (window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - THRESHOLD) { + onLoadMore(); } }); +}; + +export const initMovieClick = (onSelect: (id: number) => void) => { + document.addEventListener('click', (e: MouseEvent) => { + const li = (e.target as HTMLElement).closest('.thumbnail-list li'); + if (!li?.dataset.movieId) return; + onSelect(Number(li.dataset.movieId)); + }); +}; + +export const initRatingClick = (onRate: (rating: number) => void) => { + document.querySelector('#rate-stars')?.addEventListener('click', (e) => { + const target = e.target as HTMLImageElement; + const index = target.dataset.index; + if (!index) return; + onRate(Number(index) * 2); + }); +}; - loadMovieButton.addEventListener("click", onLoadMore); +export const initModalClose = (onClose: () => void) => { + document.addEventListener('click', (e: MouseEvent) => { + const target = e.target as HTMLElement; + if (target.closest('#closeModal') || target.id === 'modalBackground') { + onClose(); + } + }); + document.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }); }; diff --git a/src/initTemplate.ts b/src/initTemplate.ts new file mode 100644 index 000000000..7d7c3d06a --- /dev/null +++ b/src/initTemplate.ts @@ -0,0 +1,7 @@ +import template from '../templates/index.html?raw'; +import { createModalHTML } from './createHtml.ts'; + +export const initTemplate = () => { + document.querySelector('#app')!.innerHTML = template; + document.body.appendChild(createModalHTML()); +}; diff --git a/src/movieAPIResponse.ts b/src/movieAPIResponse.ts index 819769472..8b6a59251 100644 --- a/src/movieAPIResponse.ts +++ b/src/movieAPIResponse.ts @@ -1,46 +1,54 @@ -export const fetchMovies = async (moviePageCount: number) => { - try { - const response = await fetch( - `https://api.themoviedb.org/3/movie/popular?language=en-US&page=${moviePageCount}`, - { - method: "GET", - headers: { - accept: "application/json", - Authorization: `Bearer ${import.meta.env.VITE_TMDB_API_TOKEN}`, - }, - }, - ); - if (!response.ok) { - throw new Error("[ERROR]인기 영화 불러오기에 실패하였습니다."); - } - return await response.json(); - } catch (error) { - if (error instanceof Error) throw error; - throw new Error("[ERROR]네트워크 오류가 발생했습니다."); - } +export interface MoviePage { + results: import('../types/Movie.ts').Movie[]; + totalPages: number; +} + +const defaultOptions = { + method: 'GET', + headers: { + accept: 'application/json', + Authorization: `Bearer ${import.meta.env.VITE_TMDB_API_TOKEN}`, + }, +}; + +const get = async (url: string, errorMessage: string, signal?: AbortSignal) => { + const response = await fetch(url, { ...defaultOptions, signal }); + if (!response.ok) + throw new Error(`[ERROR] ${response.status} ${response.statusText} - ${errorMessage}`); + return response.json(); +}; + +const apiFetch = async (url: string, errorMessage: string, signal?: AbortSignal): Promise => { + const json = await get(url, errorMessage, signal); + return { results: json.results, totalPages: json.total_pages }; +}; + +export const fetchMovies = (page: number, signal?: AbortSignal) => { + const popularMoviesUrl = new URL('https://api.themoviedb.org/3/movie/popular'); + popularMoviesUrl.searchParams.set('language', 'en-US'); + popularMoviesUrl.searchParams.set('page', String(page)); + return apiFetch(popularMoviesUrl.href, '인기 영화 불러오기에 실패하였습니다.', signal); +}; + +export const fetchSearchedMovies = (keyword: string, page: number, signal?: AbortSignal) => { + const searchMoviesUrl = new URL('https://api.themoviedb.org/3/search/movie'); + searchMoviesUrl.searchParams.set('query', keyword); + searchMoviesUrl.searchParams.set('language', 'en-US'); + searchMoviesUrl.searchParams.set('page', String(page)); + return apiFetch(searchMoviesUrl.href, '검색 영화 불러오기에 실패하였습니다.', signal); }; -export const fetchSearchedMovies = async ( - searchKeyword: string, - searchPageCount: number, -) => { - try { - const response = await fetch( - `https://api.themoviedb.org/3/search/movie?query=${searchKeyword}&page=${searchPageCount}`, - { - method: "GET", - headers: { - accept: "application/json", - Authorization: `Bearer ${import.meta.env.VITE_TMDB_API_TOKEN}`, - }, - }, - ); - if (!response.ok) { - throw new Error("[ERROR]검색 영화 불러오기에 실패하였습니다."); - } - return await response.json(); - } catch (error) { - if (error instanceof Error) throw error; - throw new Error("[ERROR]네트워크 오류가 발생했습니다."); - } +export const fetchMovieDetail = async (id: number, signal?: AbortSignal): Promise => { + const url = new URL(`https://api.themoviedb.org/3/movie/${id}`); + url.searchParams.set('language', 'en-US'); + const json = await get(url.href, '영화 상세 정보 불러오기에 실패하였습니다.', signal); + return { + id: json.id, + title: json.title, + poster_path: json.poster_path, + vote_average: json.vote_average, + overview: json.overview, + release_date: json.release_date, + genres: json.genres, + }; }; diff --git a/src/movieRenderer.ts b/src/movieRenderer.ts deleted file mode 100644 index 08d3bb82b..000000000 --- a/src/movieRenderer.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { Movie } from "../types/Movie.ts"; -import { - createMovieItemHTML, - createBannerHTML, - createNoResultHTML, -} from "./createHtml.ts"; -import DOM from "./dom.ts"; - -const bannerBaseURL = "https://image.tmdb.org/t/p/w1920_and_h800_multi_faces"; - -export const renderMovies = (movies: Movie[]) => { - movies.forEach((movie: Movie) => { - const li = createMovieItemHTML(movie); - attachSkeletonEvents(li); - DOM.thumbnailList?.appendChild(li); - }); -}; - -const attachSkeletonEvents = (li: HTMLLIElement) => { - const img = li.querySelector(".thumbnail")!; - const removeSkeleton = () => { - li.querySelector(".item")?.classList.remove("skeleton"); - li.querySelector(".skeleton-poster")?.remove(); - li.querySelector(".skeleton-rate")?.remove(); - li.querySelector(".skeleton-title")?.remove(); - }; - - img.addEventListener("load", removeSkeleton, { once: true }); - img.addEventListener( - "error", - () => { - img.src = "./images/no_image.png"; - removeSkeleton(); - }, - { once: true }, - ); -}; - -export const renderBanner = (movie: Movie) => { - if (DOM.backgroundContainer) { - DOM.backgroundContainer.style.backgroundImage = - `url("${bannerBaseURL + movie.backdrop_path}")`; - } - - DOM.banner?.appendChild(createBannerHTML(movie)); -}; - -export const renderSearchedMovies = (movies: Movie[]) => { - if (DOM.thumbnailList && movies.length === 0) { - DOM.thumbnailList.appendChild(createNoResultHTML()); - } - - movies.forEach((movie: Movie) => { - const li = createMovieItemHTML(movie); - attachSkeletonEvents(li); - DOM.thumbnailList?.appendChild(li); - }); -}; diff --git a/src/presentation/CustomRatingRender.ts b/src/presentation/CustomRatingRender.ts new file mode 100644 index 000000000..58f17d03e --- /dev/null +++ b/src/presentation/CustomRatingRender.ts @@ -0,0 +1,42 @@ +const renderStars = (rate: number | null) => { + const starsEl = document.querySelector('#rate-stars'); + if (!starsEl) return; + starsEl.innerHTML = ''; + const filledCount = rate === null ? 0 : rate / 2; + for (let i = 1; i <= 5; i++) { + const img = document.createElement('img'); + if (i <= filledCount) { + img.src = './images/star_filled.png'; + } else { + img.src = './images/star_empty.png'; + } + img.dataset.index = String(i); + starsEl.appendChild(img); + } +}; + +const renderEvaluate = (rate: number | null) => { + const evaluateEl = document.querySelector('#rate-evaluate'); + if (!evaluateEl) return; + evaluateEl.textContent = rate === null ? '아직 별점을 남기지 않으셨습니다' : ratingMap.get(rate)!; +}; + +const renderScore = (rate: number | null) => { + const scoreEl = document.querySelector('#rate-score'); + if (!scoreEl) return; + scoreEl.textContent = rate === null ? '(0/10)' : `(${rate}/10)`; +}; + +export const renderCustomRating = (rate: number | null) => { + renderStars(rate); + renderEvaluate(rate); + renderScore(rate); +} + +const ratingMap = new Map([ + [2 ,"최악이예요"], + [4 , "별로예요"], + [6 , "보통이에요"], + [8 , "재미있어요"], + [10 , "명작이에요"] +]); \ No newline at end of file diff --git a/src/presentation/CustomRatingRepository.ts b/src/presentation/CustomRatingRepository.ts new file mode 100644 index 000000000..087b51322 --- /dev/null +++ b/src/presentation/CustomRatingRepository.ts @@ -0,0 +1,13 @@ +import { RatingStorage } from '../../types/RatingStorage.ts'; + +export class CustomRatingRepository { + constructor(private storage: RatingStorage) {} + + getCustomRate(movieId: number): number | null { + return this.storage.get(movieId); + } + + saveCustomRate(movieId: number, rating: number): void { + this.storage.save(movieId, rating); + } +} diff --git a/src/presentation/LocalStorageRatingStorage.ts b/src/presentation/LocalStorageRatingStorage.ts new file mode 100644 index 000000000..2caed8e31 --- /dev/null +++ b/src/presentation/LocalStorageRatingStorage.ts @@ -0,0 +1,11 @@ +import { RatingStorage } from '../../types/RatingStorage.ts'; + +export const localStorageRatingStorage: RatingStorage = { + get(movieId) { + const rate = localStorage.getItem(String(movieId)); + return rate ? Number(rate) : null; + }, + save(movieId, rating) { + localStorage.setItem(String(movieId), String(rating)); + }, +}; diff --git a/src/presentation/ModalController.ts b/src/presentation/ModalController.ts new file mode 100644 index 000000000..0a5288184 --- /dev/null +++ b/src/presentation/ModalController.ts @@ -0,0 +1,48 @@ +import { MovieDetail } from '../../types/MovieDetail.ts'; +import { MovieSelection } from '../domain/MovieSelection.ts'; +import { fetchMovieDetail } from '../movieAPIResponse.ts'; +import { renderCustomRating } from './CustomRatingRender.ts'; +import { CustomRatingRepository } from './CustomRatingRepository.ts'; +import { localStorageRatingStorage } from './LocalStorageRatingStorage.ts'; +import { renderModal } from './ModalRenderer.ts'; +import { showError } from './MovieRenderer.ts'; + +const selection = new MovieSelection(); +const ratingRepository = new CustomRatingRepository(localStorageRatingStorage); + +const handleSuccess = (detail: MovieDetail) => { + const savedRating = ratingRepository.getCustomRate(detail.id); + selection.select(detail, savedRating); + renderModal(selection); + renderCustomRating(selection.rating); +}; + +const handleError = (error: unknown) => { + showError(error); +}; + +export const openModal = async (id: number) => { + try { + const detail = await fetchMovieDetail(id); + handleSuccess(detail); + } catch (error) { + handleError(error); + } +}; + +export const rateMovie = (rating: number) => { + const movieId = selection.selected?.id; + if (!movieId) return; + selection.rate(rating); + try { + ratingRepository.saveCustomRate(movieId, rating); + } catch { + handleError('[ERROR]별점 저장에 실패했습니다.'); + } + renderCustomRating(selection.rating); +}; + +export const closeModal = () => { + selection.close(); + renderModal(selection); +}; diff --git a/src/presentation/ModalRenderer.ts b/src/presentation/ModalRenderer.ts new file mode 100644 index 000000000..d34e81861 --- /dev/null +++ b/src/presentation/ModalRenderer.ts @@ -0,0 +1,39 @@ +import type { MovieSelection } from '../domain/MovieSelection.ts'; + +const posterBaseURL = 'https://image.tmdb.org/t/p/original'; + +const showModal = () => { + document.querySelector('#modalBackground')?.classList.add('active'); + document.body.classList.add('modal-open'); +}; + +const hideModal = () => { + document.querySelector('#modalBackground')?.classList.remove('active'); + document.body.classList.remove('modal-open'); +}; + +export const renderModal = (state: MovieSelection) => { + if (!state.isOpen) { + hideModal(); + return; + } + + const detail = state.selected!; + + const img = document.querySelector('.modal-image img'); + if (img) img.src = `${posterBaseURL}${detail.poster_path}`; + + const title = document.querySelector('.modal-description h2'); + if (title) title.textContent = detail.title; + + const category = document.querySelector('.modal-description .category'); + if (category) category.textContent = `${detail.release_date.slice(0, 4)} · ${detail.genres.map((g) => g.name).join(', ')}`; + + const rate = document.querySelector('#average-score') + if (rate) rate.textContent = `${detail.vote_average.toFixed(1)}` + + const description = document.querySelector('.modal-description .detail'); + if (description) description.textContent = detail.overview; + + showModal(); +}; diff --git a/src/presentation/MovieController.ts b/src/presentation/MovieController.ts new file mode 100644 index 000000000..5f26ae26d --- /dev/null +++ b/src/presentation/MovieController.ts @@ -0,0 +1,53 @@ +import { MovieBrowser } from '../domain/MovieBrowser.ts'; +import { type FetchStrategy, type MoviePage, popularStrategy, searchStrategy } from './fetchStrategies.ts'; +import { render, showError } from './MovieRenderer.ts'; + +const browser = new MovieBrowser(); +let strategy: FetchStrategy = popularStrategy; +let controller: AbortController | undefined; +let isLoading = false; + + +const createNewRequest = (): AbortSignal => { + controller?.abort(); + controller = new AbortController(); + return controller.signal; +}; + +const handleSuccess = (data: MoviePage, onSuccess?: () => void) => { + onSuccess?.(); + browser.setTotalPages(data.totalPages); + render(browser, data.results); +}; + +const handleError = (error: unknown) => { + if (error instanceof DOMException && error.name === 'AbortError') return; + showError(error); +}; + +const load = async (page: number, onSuccess?: () => void) => { + const signal = createNewRequest(); + isLoading = true; + try { + const data = await strategy(page, signal); + handleSuccess(data, onSuccess); + } catch (error) { + handleError(error); + } finally { + isLoading = false; + } +}; + +export const loadPopular = () => load(browser.currentPage); + +export const search = (keyword: string) => { + strategy = searchStrategy(keyword); + browser.startSearch(keyword); + return load(browser.currentPage); +}; + +export const loadMore = () => { + if (!browser.canLoadMore) return; + if (isLoading) return; + return load(browser.nextPageNumber, () => browser.nextPage()); +}; diff --git a/src/presentation/MovieRenderer.ts b/src/presentation/MovieRenderer.ts new file mode 100644 index 000000000..8f46a99fc --- /dev/null +++ b/src/presentation/MovieRenderer.ts @@ -0,0 +1,86 @@ +import type { Movie } from '../../types/Movie.ts'; +import type { MovieBrowser } from '../domain/MovieBrowser.ts'; +import { + createMovieItemHTML, + createBannerHTML, + createNoResultHTML, +} from '../createHtml.ts'; + +const bannerBaseURL = 'https://image.tmdb.org/t/p/w1920_and_h800_multi_faces'; + +const attachSkeletonEvents = (li: HTMLLIElement) => { + const img = li.querySelector('.thumbnail')!; + const removeSkeleton = () => { + li.querySelector('.item')?.classList.remove('skeleton'); + li.querySelector('.skeleton-poster')?.remove(); + li.querySelector('.skeleton-rate')?.remove(); + li.querySelector('.skeleton-title')?.remove(); + }; + + img.addEventListener('load', removeSkeleton, { once: true }); + img.addEventListener( + 'error', + () => { + img.src = './images/no_image.png'; + removeSkeleton(); + }, + { once: true }, + ); +}; + +const appendMovie = (movie: Movie) => { + const li = createMovieItemHTML(movie); + attachSkeletonEvents(li); + document.querySelector('.thumbnail-list')?.appendChild(li); +}; + +const renderMovieList = (movies: Movie[]) => { + movies.forEach(appendMovie); +}; + +const renderBanner = (movie: Movie) => { + const bg = document.querySelector('.background-container'); + if (bg) bg.style.backgroundImage = `url("${bannerBaseURL + movie.backdrop_path}")`; + document.querySelector('.top-rated-movie')?.appendChild(createBannerHTML(movie)); +}; + +const renderNoResult = () => { + document.querySelector('.thumbnail-list')?.appendChild(createNoResultHTML()); +}; + +const clearMovieList = () => { + const thumbnailList = document.querySelector('.thumbnail-list'); + if (thumbnailList) thumbnailList.replaceChildren(); +}; + +const showBanner = () => { + const bg = document.querySelector('.background-container'); + if (bg) bg.hidden = false; +}; + +const hideBanner = () => { + const bg = document.querySelector('.background-container'); + if (bg) bg.hidden = true; +}; + +export const render = (state: MovieBrowser, movies: Movie[]) => { + if (state.isNewSession) { + clearMovieList(); + if (state.showsBanner) { + showBanner(); + renderBanner(movies[0]); + } else { + hideBanner(); + } + } + + if (movies.length === 0) { + renderNoResult(); + } else { + renderMovieList(movies); + } +}; + +export const showError = (error: unknown) => { + alert(error instanceof Error ? error.message : '오류가 발생했습니다.'); +}; diff --git a/src/presentation/fetchStrategies.ts b/src/presentation/fetchStrategies.ts new file mode 100644 index 000000000..6e2eacbac --- /dev/null +++ b/src/presentation/fetchStrategies.ts @@ -0,0 +1,10 @@ +import { fetchMovies, fetchSearchedMovies, type MoviePage } from '../movieAPIResponse.ts'; +export type { MoviePage }; + +export type FetchStrategy = (page: number, signal: AbortSignal) => Promise; + +export const popularStrategy: FetchStrategy = (page, signal) => + fetchMovies(page, signal); + +export const searchStrategy = (keyword: string): FetchStrategy => + (page, signal) => fetchSearchedMovies(keyword, page, signal); diff --git a/templates/index.html b/templates/index.html index 8e9ce9266..17f193d4d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -7,6 +7,7 @@ + 영화 리뷰 @@ -76,7 +77,7 @@

지금 인기 있는 영화

    - +