diff --git a/README.md b/README.md index 5c0b389753..35995714b7 100644 --- a/README.md +++ b/README.md @@ -1,130 +1,46 @@ # 요구 사항 목록 -## 영상 목록 조회 +## 무한 스크롤 -- [x] 영화 목록의 1페이지를 불러오며 더보기 버튼을 누르면 그 다음의 영화 목록을 불러 올 수 있다. -- [x] 페이지 끝에 도달한 경우에는 더보기 버튼을 화면에 출력하지 않는다. -- [x] 영화는 한 번의 요청당 20개씩 영화 목록을 보여준다. -- [x] 영화 목록을 불러오는 동안 Skeleton UI 를 보여준다 - - [x] Skeleton UI는 템플릿으로 제공되는 파일 이외로 자유롭게 구현할 수 있다. +- [x] 더 보기 버튼 제거 +- [x] page search params 제거 -## 검색 +## 영화 상세정보 조회 -- [x] 영화 검색 API를 이용하여 내가 보고 싶은 영화를 검색할 수 있다. - - [x] 엔터키를 눌러 검색할 수 있다 - - [x] 검색 버튼을 클릭하여 검색할 수 있다 - - [x] 영화 목록 조회와 같이 검색한 결과에 한해 정보를 보여주는 화면의 요구사항은 동일하다 +- [x] 영화 포스터나 제목을 클릭하면 예고편, 줄거리 모달 렌더링 +- [x] x 버튼, esc로 모달 닫기 +- [x] 이벤트 위임 -> renderMovieItems에서 처리 -## 오류 +## 별점 매기기 -- [x] 오류가 발생하는 경우에는 사용자를 위한 오류 메시지를 띄워 준다. - - [x] 어떤 오류를 대응해야 하고, 어떤 UI로 보여줄 것인지는 자율적으로 결정한다. +- [x] 상세 모달 안에서 등록한 별점이 없는 경우 등록 가능 +- [x] 이미 등록한 별점이 있는 경우 별점 렌더링 +- [x] 별점은 로컬스토리지에 저장, api 서버 연결 가능성을 고려하여 확장성 있게 설계 ---- - -# 기능 목록 - -## 퍼블리싱 - -### 기본 UI - -- [x] 영화 목록 검색 input -- [x] 정렬 버튼 삭제 -- [x] 더보기 버튼 - - [x] 다음 페이지 없으면 제거 -- [x] 영화 리스트 background 변경 - -### 검색 시 UI - -- [x] 검색 시(엔터, 검색 버튼 클릭) 인기 영화 배너 hidden -- [x] 검색 결과 없을 때 영화 배열 및 더 보기 버튼 hidden -- [x] 검색 결과 없음 UI - -### 조회 에러 UI - -- [x] 검색 결과가 없음 UI에서 텍스트 변경 + 재시도 버튼 추가 - -## 기능 - -### 메인 페이지 - -- [x] 최초 접근 시 인기순 영화 20개 렌더링 +## UI/UX 개선 -### 더보기 버튼 - -- [x] api 함수 받아서 호출 - -### 검색 - -- [x] 검색 버튼 클릭 or 엔터 입력 시 검색 api 호출 -- [x] 검색 버튼 클릭 시 keyword 파라미터 추가 - - [x] 이전 검색 기록 남아있는 버그 수정 - -### 로고 - -- [x] 클릭 시 기본 UI로 +- [x] width에 따라 column 수 조절 +- [x] 헤더 및 배너 UI 레이아웃도 변경 +- [x] 데스크톱: 중앙 모달 +- [x] 태블릿: 하단 모달 +- [x] 모바일: 하단 모달 + 중앙 정렬 + 썸네일 제거 +- [x] 모달 렌더링 시 백그라운드 스크롤 제한 --- ## 미룬이 - [ ] getElementById로 가져온 html 요소 타입 좁혀주는 유틸 -- [x] TMDBError class 사용에 대해 논의 -- [x] 빈 keyword 검색 시 기본 UI로 변경 - [ ] parameter로 관리하는 page가 1일 때도 보여지게 하는게 맞을까? 안 보이게 할까? -- [x] E2E 테스트 - [ ] 돔 접근을 어디서 할 것인지 기준 세우기 (인자로 받기 vs 함수 안에서 호출하기) - [ ] 동일 검색어 api 요청 막기 ## E2E 테스트 목록 -- [x] 처음 앱에 도달했을 때 메인 구성 요소가 렌더링 되는지 테스트 - - given: 없음 - - when: 페이지 진입 - - then - - main-thumbnail-list 렌더링 - - banner 렌더링 - - logo 렌더링 - - search input 렌더링 - -- [x] 더 보기 기능 (main) - - given: 페이지 진입, 더 보기 버튼 - - when: 더 보기 버튼 클릭 - - then: 기존 영화 목록에 추가 영화 목록이 append됨 - -- [x] 더 보기 기능 (search) - - given: 검색어 입력 후 검색, 더 보기 버튼 - - when: 더 보기 버튼 클릭 - - then: 기존 검색 결과 목록에 추가 결과가 append됨 - -- [x] 검색 기능 - 버튼 클릭 - - given: 영화 검색 input에 검색어 입력 - - when: 검색 버튼 클릭 - - then - - search-thumbnail-list 렌더링 - - URL에 keyword parameter 추가 - - subtitle이 "${검색어} 검색 결과"로 변경 - -- [x] 검색 기능 - 엔터 입력 - - given: 영화 검색 input에 검색어 입력 - - when: 엔터 키 입력 - - then - - search-thumbnail-list 렌더링 - - URL에 keyword parameter 추가 - - subtitle이 "${검색어} 검색 결과"로 변경 - -- [x] 검색 후 메인으로 복귀(logo 클릭) - - given: 검색 결과 화면 - - when: 로고 클릭 - - then - - main-thumbnail-list 렌더 - - banner 렌더링 - - keyword parameter 제거 - -- [x] 검색 후 메인으로 복귀(빈 문자열 검색) - - given: 검색 결과 화면 - - when: 빈 문자열 검색 - - then - - main-thumbnail-list 렌더링 - - banner 렌더링 - - keyword parameter 제거 +- [x] 무한스크롤 +- [x] 상세 모달 렌더링 +- [x] x 버튼으로 모달 닫기 +- [x] esc 입력으로 모달 닫기 +- [x] 별점 입력 +- [x] 별점 업데이트 +- [x] 새로고침 시에도 별점 유지 diff --git a/cypress/e2e/InfiniteScroll.cy.ts b/cypress/e2e/InfiniteScroll.cy.ts new file mode 100644 index 0000000000..24989cb1cd --- /dev/null +++ b/cypress/e2e/InfiniteScroll.cy.ts @@ -0,0 +1,108 @@ +import { interceptPopularError, interceptPopularLastPage, interceptPopularPage1, interceptPopularPage2, interceptSearchError, interceptSearchLastPage, interceptSearchPage1, interceptSearchPage2 } from "./spec"; + +describe("메인 무한 스크롤 동작 테스트", () => { + it("스크롤이 observer target에 도달하면 추가 영화가 append된다", () => { + interceptPopularPage1(); + cy.visit("/"); + cy.wait("@getPopularPage1"); + + cy.get("#popular-thumbnail-list li").then(($initialItems) => { + const initialCount = $initialItems.length; + + interceptPopularPage2(); + cy.get("#home-observer-target").scrollIntoView(); + cy.wait("@getPopularPage2"); + + cy.get("#popular-thumbnail-list li").should( + "have.length.greaterThan", + initialCount, + ); + }); + }); + + it("마지막 페이지인 경우 observer target이 렌더링되지 않는다", () => { + interceptPopularLastPage(); + cy.visit("/"); + cy.wait("@getPopularLastPage"); + + cy.get("#home-observer-target").should("not.exist"); + }); +}); + +describe("검색 무한 스크롤 동작 테스트", () => { + it("검색 후 스크롤이 observer target에 도달하면 추가 결과가 append된다", () => { + interceptPopularPage1(); + cy.visit("/"); + cy.wait("@getPopularPage1"); + + interceptSearchPage1(); + cy.get("#search-input").type("인터스텔라"); + cy.get("#search-button").click(); + cy.wait("@getSearchPage1"); + + cy.get("#search-thumbnail-list li").then(($initialItems) => { + const initialCount = $initialItems.length; + + interceptSearchPage2(); + cy.get("#search-observer-target").scrollIntoView(); + cy.wait("@getSearchPage2"); + + cy.get("#search-thumbnail-list li").should( + "have.length.greaterThan", + initialCount, + ); + }); + }); + + it("검색 결과가 마지막 페이지인 경우 observer target이 렌더링되지 않는다", () => { + interceptPopularPage1(); + cy.visit("/"); + cy.wait("@getPopularPage1"); + + interceptSearchLastPage(); + cy.get("#search-input").type("인터스텔라"); + cy.get("#search-button").click(); + cy.wait("@getSearchLastPage"); + + cy.get("#search-observer-target").should("not.exist"); + }); +}); + +describe("홈 무한 스크롤 에러 테스트", () => { + it("스크롤로 추가 로딩 중 에러가 발생하면 에러 메시지가 alert된다", () => { + interceptPopularPage1(); + cy.visit("/"); + cy.wait("@getPopularPage1"); + + interceptPopularError(); + const alertStub = cy.stub(); + cy.on("window:alert", alertStub); + + cy.get("#home-observer-target").scrollIntoView(); + cy.wait("@getPopularError").then(() => { + expect(alertStub).to.have.been.called; + }); + }); +}); + +describe("검색 무한 스크롤 에러 테스트", () => { + it("검색 후 스크롤로 추가 로딩 중 에러가 발생하면 에러 메시지가 alert된다", () => { + interceptPopularPage1(); + cy.visit("/"); + cy.wait("@getPopularPage1"); + + interceptSearchPage1(); + cy.get("#search-input").type("인터스텔라"); + cy.get("#search-button").click(); + cy.wait("@getSearchPage1"); + + interceptSearchError(); + const alertStub = cy.stub(); + cy.on("window:alert", alertStub); + + cy.get("#search-observer-target").scrollIntoView(); + cy.wait("@getSearchError").then(() => { + expect(alertStub).to.have.been.called; + }); + }); +}); diff --git a/cypress/e2e/MainRender.cy.ts b/cypress/e2e/MainRender.cy.ts index 0b07cf73c5..94f5936cb7 100644 --- a/cypress/e2e/MainRender.cy.ts +++ b/cypress/e2e/MainRender.cy.ts @@ -1,4 +1,4 @@ -import { interceptPopularPage1, interceptPopularError } from "./spec"; +import { interceptPopularError, interceptPopularPage1 } from "./spec"; describe("처음 앱에 도달했을 때 메인 구성 요소가 렌더링 되는지 테스트", () => { beforeEach(() => { @@ -7,8 +7,8 @@ describe("처음 앱에 도달했을 때 메인 구성 요소가 렌더링 되 cy.wait("@getPopularPage1"); }); - it("main-thumbnail-list가 렌더링된다", () => { - cy.get("#main-thumbnail-list").should("be.visible"); + it("popular-thumbnail-list가 렌더링된다", () => { + cy.get("#popular-thumbnail-list").should("be.visible"); }); it("banner가 렌더링된다", () => { diff --git a/cypress/e2e/MovieModal.cy.ts b/cypress/e2e/MovieModal.cy.ts new file mode 100644 index 0000000000..efd6e874d2 --- /dev/null +++ b/cypress/e2e/MovieModal.cy.ts @@ -0,0 +1,107 @@ +import { + interceptMovieDetail, + interceptMovieDetailError, + interceptPopularPage1, +} from "./spec"; + +const openModal = () => { + interceptMovieDetail(); + cy.get("#popular-thumbnail-list li").first().click(); + cy.wait("@getMovieDetail"); +}; + +describe("영화 상세 모달 렌더링 테스트", () => { + beforeEach(() => { + interceptPopularPage1(); + cy.visit("/"); + cy.wait("@getPopularPage1"); + }); + + it("영화 아이템을 클릭하면 상세 모달이 렌더링된다", () => { + openModal(); + cy.get("#modal-dialog").should("be.visible"); + }); + + it("모달에 영화 제목이 표시된다", () => { + openModal(); + cy.get(".modal-description h2").should("contain.text", "영화1"); + }); + + it("모달에 장르가 표시된다", () => { + openModal(); + cy.get(".modal-description .category").should("contain.text", "액션"); + cy.get(".modal-description .category").should("contain.text", "모험"); + }); + + it("모달에 평균 별점이 표시된다", () => { + openModal(); + cy.get(".modal-rate .rate span").should("contain.text", "8"); + }); + + it("모달에 내 별점이 표시된다", () => { + openModal(); + cy.get("#my-rate").should("be.visible"); + }); + + it("모달에 줄거리가 표시된다", () => { + openModal(); + cy.get(".detail p").should("contain.text", "영화1의 줄거리입니다."); + }); +}); + +describe("영화 상세 모달 닫기 테스트", () => { + beforeEach(() => { + interceptPopularPage1(); + cy.visit("/"); + cy.wait("@getPopularPage1"); + }); + + it("닫기 버튼을 클릭하면 모달이 닫힌다", () => { + openModal(); + cy.get("#closeModal").click({ force: true }); + cy.get("#modal-dialog").should("not.exist"); + }); + + it("ESC 키를 누르면 모달이 닫힌다", () => { + openModal(); + cy.get("#modal-dialog").then(($dialog) => { + $dialog[0].dispatchEvent(new Event("cancel")); + }); + cy.get("#modal-dialog").should("not.exist"); + }); + + it("모달 내부를 클릭하면 모달이 닫히지 않는다", () => { + openModal(); + cy.get(".modal-description h2").click(); + cy.get("#modal-dialog").should("be.visible"); + }); +}); + +describe("영화 상세 모달 에러 테스트", () => { + beforeEach(() => { + interceptPopularPage1(); + cy.visit("/"); + cy.wait("@getPopularPage1"); + }); + + it("영화 상세 조회에 실패하면 에러 메시지가 alert된다", () => { + interceptMovieDetailError(); + const alertStub = cy.stub(); + cy.on("window:alert", alertStub); + + cy.get("#popular-thumbnail-list li").first().click(); + cy.wait("@getMovieDetailError").then(() => { + expect(alertStub).to.have.been.called; + }); + }); + + it("영화 상세 조회에 실패하면 모달이 렌더링되지 않는다", () => { + interceptMovieDetailError(); + cy.on("window:alert", () => {}); + + cy.get("#popular-thumbnail-list li").first().click(); + cy.wait("@getMovieDetailError"); + + cy.get("#modal-dialog").should("not.exist"); + }); +}); diff --git a/cypress/e2e/MovieRate.cy.ts b/cypress/e2e/MovieRate.cy.ts new file mode 100644 index 0000000000..c933fe87e3 --- /dev/null +++ b/cypress/e2e/MovieRate.cy.ts @@ -0,0 +1,121 @@ +import { interceptMovieDetail, interceptPopularPage1 } from "./spec"; + +const openModal = () => { + interceptMovieDetail(); + cy.get("#popular-thumbnail-list li").first().click(); + cy.wait("@getMovieDetail"); +}; + +describe("초기 내 별점 렌더링 테스트", () => { + beforeEach(() => { + localStorage.clear(); + interceptPopularPage1(); + cy.visit("/"); + cy.wait("@getPopularPage1"); + }); + + it("별점을 등록하지 않은 상태에서는 빈 별 5개가 렌더링된다", () => { + openModal(); + cy.get("#rate-button-container button").should("have.length", 5); + cy.get("#rate-button-container img[src*='star_empty']").should( + "have.length", + 5, + ); + }); + + it("별점을 등록하지 않은 상태에서는 '별점을 남겨주세요' 문구가 표시된다", () => { + openModal(); + cy.get("#my-rate .comment").should("contain.text", "별점을 남겨주세요"); + }); + + it("별점을 등록하지 않은 상태에서 점수는 (0/10)이다", () => { + openModal(); + cy.get("#my-rate .score").should("contain.text", "(0/10)"); + }); +}); + +describe("별점 등록 테스트", () => { + beforeEach(() => { + localStorage.clear(); + interceptPopularPage1(); + cy.visit("/"); + cy.wait("@getPopularPage1"); + }); + + it("세 번째 별을 클릭하면 채워진 별 3개와 빈 별 2개가 렌더링된다", () => { + openModal(); + cy.get("#rate-button-container button").eq(2).click(); + + cy.get("#rate-button-container img[src*='star_filled']").should( + "have.length", + 3, + ); + cy.get("#rate-button-container img[src*='star_empty']").should( + "have.length", + 2, + ); + }); + + it("세 번째 별을 클릭하면 '보통이에요' 문구가 표시된다", () => { + openModal(); + cy.get("#rate-button-container button").eq(2).click(); + + cy.get("#my-rate .comment").should("contain.text", "보통이에요"); + }); + + it("세 번째 별을 클릭하면 점수가 (6/10)으로 표시된다", () => { + openModal(); + cy.get("#rate-button-container button").eq(2).click(); + + cy.get("#my-rate .score").should("contain.text", "(6/10)"); + }); +}); + +describe("별점 수정 테스트", () => { + beforeEach(() => { + localStorage.clear(); + interceptPopularPage1(); + cy.visit("/"); + cy.wait("@getPopularPage1"); + }); + + it("별점을 등록한 후 다른 별을 클릭하면 별점이 수정된다", () => { + openModal(); + cy.get("#rate-button-container button").eq(2).click(); + cy.get("#my-rate .comment").should("contain.text", "보통이에요"); + + cy.get("#rate-button-container button").eq(4).click(); + cy.get("#rate-button-container img[src*='star_filled']").should( + "have.length", + 5, + ); + cy.get("#my-rate .comment").should("contain.text", "명작이에요"); + cy.get("#my-rate .score").should("contain.text", "(10/10)"); + }); +}); + +describe("새로고침 후 별점 불러오기 테스트", () => { + beforeEach(() => { + localStorage.clear(); + interceptPopularPage1(); + cy.visit("/"); + cy.wait("@getPopularPage1"); + }); + + it("별점을 등록한 후 새로고침하면 등록한 별점이 유지된다", () => { + openModal(); + cy.get("#rate-button-container button").eq(3).click(); + cy.get("#closeModal").click({ force: true }); + + cy.reload(); + cy.wait("@getPopularPage1"); + + openModal(); + cy.get("#rate-button-container img[src*='star_filled']").should( + "have.length", + 4, + ); + cy.get("#my-rate .comment").should("contain.text", "재미있어요"); + cy.get("#my-rate .score").should("contain.text", "(8/10)"); + }); +}); diff --git a/cypress/e2e/ReturnToMain.cy.ts b/cypress/e2e/ReturnToMain.cy.ts index 014a025fcf..55120dd6b9 100644 --- a/cypress/e2e/ReturnToMain.cy.ts +++ b/cypress/e2e/ReturnToMain.cy.ts @@ -23,8 +23,8 @@ describe("검색 작업 후 로고를 클릭했을 때 메인으로 복귀 동 cy.get("#background-container").should("be.visible"); }); - it("main-thumbnail-list가 렌더링된다", () => { - cy.get("#main-thumbnail-list").should("be.visible"); + it("popular-thumbnail-list가 렌더링된다", () => { + cy.get("#popular-thumbnail-list").should("be.visible"); }); }); @@ -52,7 +52,7 @@ describe("검색 작업 후 빈 문자열을 입력했을 때 메인으로 복 cy.get("#background-container").should("be.visible"); }); - it("main-thumbnail-list가 렌더링된다", () => { - cy.get("#main-thumbnail-list").should("be.visible"); + it("popular-thumbnail-list가 렌더링된다", () => { + cy.get("#popular-thumbnail-list").should("be.visible"); }); }); diff --git a/cypress/e2e/SeeMoreButton.cy.ts b/cypress/e2e/SeeMoreButton.cy.ts deleted file mode 100644 index 217be185f4..0000000000 --- a/cypress/e2e/SeeMoreButton.cy.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { - interceptPopularError, - interceptPopularPage1, - interceptPopularPage2, - interceptSearchError, - interceptSearchPage1, - interceptSearchPage2, -} from "./spec"; - -describe("메인 단계 영화 결과 더 보기 버튼 클릭했을 때 동작 테스트", () => { - it("더 보기 버튼 클릭 시 기존 영화 목록에 추가 영화가 append된다", () => { - interceptPopularPage1(); - cy.visit("/"); - cy.wait("@getPopularPage1"); - - cy.get("#main-thumbnail-list li").then(($initialItems) => { - const initialCount = $initialItems.length; - - interceptPopularPage2(); - cy.get("#main-see-more-button").click(); - cy.wait("@getPopularPage2"); - - cy.get("#main-thumbnail-list li").should( - "have.length.greaterThan", - initialCount, - ); - }); - }); - - it("더 보기 버튼 클릭 시 에러가 발생하면 에러 메시지가 alert된다", () => { - interceptPopularPage1(); - cy.visit("/"); - cy.wait("@getPopularPage1"); - - interceptPopularError(); - const alertStub = cy.stub(); - cy.on("window:alert", alertStub); - - cy.get("#main-see-more-button").click(); - cy.wait("@getPopularError").then(() => { - expect(alertStub); - }); - }); -}); - -describe("검색 단계 영화 결과 더 보기 버튼 클릭했을 때 동작 테스트", () => { - it("검색 후 더 보기 버튼 클릭 시 기존 검색 결과에 추가 결과가 append된다", () => { - interceptPopularPage1(); - cy.visit("/"); - cy.wait("@getPopularPage1"); - - interceptSearchPage1(); - cy.get("#search-input").type("인터스텔라"); - cy.get("#search-button").click(); - cy.wait("@getSearchPage1"); - - cy.get("#search-thumbnail-list li").then(($initialItems) => { - const initialCount = $initialItems.length; - - interceptSearchPage2(); - cy.get("#search-see-more-button").click(); - cy.wait("@getSearchPage2"); - - cy.get("#search-thumbnail-list li").should( - "have.length.greaterThan", - initialCount, - ); - }); - }); - - it("검색 후 더 보기 버튼 클릭 시 에러가 발생하면 에러 메시지가 alert된다", () => { - interceptPopularPage1(); - cy.visit("/"); - cy.wait("@getPopularPage1"); - - interceptSearchPage1(); - cy.get("#search-input").type("인터스텔라"); - cy.get("#search-button").click(); - cy.wait("@getSearchPage1"); - - interceptSearchError(); - const alertStub = cy.stub(); - cy.on("window:alert", alertStub); - - cy.get("#search-see-more-button").click(); - cy.wait("@getSearchError").then(() => { - expect(alertStub); - }); - }); -}); diff --git a/cypress/e2e/spec.ts b/cypress/e2e/spec.ts index 52b79b9d45..6525169907 100644 --- a/cypress/e2e/spec.ts +++ b/cypress/e2e/spec.ts @@ -1,5 +1,6 @@ const POPULAR_API = "**/movie/popular**"; const SEARCH_API = "**/search/movie**"; +const MOVIE_DETAIL_API = /\/movie\/\d+/; export const interceptPopularPage1 = () => { cy.intercept("GET", POPULAR_API, { fixture: "popularMoviesPage1.json" }).as( @@ -25,6 +26,18 @@ export const interceptSearchPage2 = () => { ); }; +export const interceptPopularLastPage = () => { + cy.intercept("GET", POPULAR_API, { fixture: "popularMoviesLastPage.json" }).as( + "getPopularLastPage", + ); +}; + +export const interceptSearchLastPage = () => { + cy.intercept("GET", SEARCH_API, { fixture: "searchMoviesLastPage.json" }).as( + "getSearchLastPage", + ); +}; + export const interceptPopularError = () => { cy.intercept("GET", POPULAR_API, { statusCode: 500 }).as("getPopularError"); }; @@ -32,3 +45,15 @@ export const interceptPopularError = () => { export const interceptSearchError = () => { cy.intercept("GET", SEARCH_API, { statusCode: 500 }).as("getSearchError"); }; + +export const interceptMovieDetail = () => { + cy.intercept("GET", MOVIE_DETAIL_API, { fixture: "movieDetail.json" }).as( + "getMovieDetail", + ); +}; + +export const interceptMovieDetailError = () => { + cy.intercept("GET", MOVIE_DETAIL_API, { statusCode: 500 }).as( + "getMovieDetailError", + ); +}; diff --git a/cypress/fixtures/movieDetail.json b/cypress/fixtures/movieDetail.json new file mode 100644 index 0000000000..f8a1d7b53d --- /dev/null +++ b/cypress/fixtures/movieDetail.json @@ -0,0 +1,31 @@ +{ + "id": 1, + "title": "영화1", + "poster_path": "/poster1.jpg", + "backdrop_path": "/back1.jpg", + "vote_average": 8.0, + "overview": "영화1의 줄거리입니다.", + "release_date": "2024-01-01", + "adult": false, + "original_language": "ko", + "original_title": "Movie1", + "popularity": 100, + "video": false, + "vote_count": 1000, + "genres": [ + { "id": 28, "name": "액션" }, + { "id": 12, "name": "모험" } + ], + "belongs_to_collection": null, + "budget": 100000000, + "homepage": "", + "imdb_id": "tt0000001", + "origin_country": ["KR"], + "production_companies": [], + "production_countries": [], + "revenue": 200000000, + "runtime": 120, + "spoken_languages": [], + "status": "Released", + "tagline": "" +} diff --git a/cypress/fixtures/popularMoviesLastPage.json b/cypress/fixtures/popularMoviesLastPage.json new file mode 100644 index 0000000000..8dd575edf1 --- /dev/null +++ b/cypress/fixtures/popularMoviesLastPage.json @@ -0,0 +1,8 @@ +{ + "page": 1, + "results": [ + { "id": 1, "title": "영화1", "poster_path": "/poster1.jpg", "backdrop_path": "/back1.jpg", "vote_average": 8.0, "overview": "줄거리1", "release_date": "2024-01-01", "adult": false, "genre_ids": [28], "original_language": "ko", "original_title": "Movie1", "popularity": 100, "video": false, "vote_count": 1000 } + ], + "total_pages": 1, + "total_results": 1 +} diff --git a/cypress/fixtures/searchMoviesLastPage.json b/cypress/fixtures/searchMoviesLastPage.json new file mode 100644 index 0000000000..8dd575edf1 --- /dev/null +++ b/cypress/fixtures/searchMoviesLastPage.json @@ -0,0 +1,8 @@ +{ + "page": 1, + "results": [ + { "id": 1, "title": "영화1", "poster_path": "/poster1.jpg", "backdrop_path": "/back1.jpg", "vote_average": 8.0, "overview": "줄거리1", "release_date": "2024-01-01", "adult": false, "genre_ids": [28], "original_language": "ko", "original_title": "Movie1", "popularity": 100, "video": false, "vote_count": 1000 } + ], + "total_pages": 1, + "total_results": 1 +} diff --git a/index.html b/index.html index 993b84b899..4e9ac30b2e 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,7 @@ + 영화 리뷰 @@ -27,183 +28,12 @@ -
- -
-
-
- - -
-

- -
-
-

지금 인기 있는 영화

- - - - - - - - - - -
diff --git a/images/logo.png b/public/images/logo.png similarity index 100% rename from images/logo.png rename to public/images/logo.png diff --git a/images/modal_button_close.png b/public/images/modal_button_close.png similarity index 100% rename from images/modal_button_close.png rename to public/images/modal_button_close.png diff --git a/images/search.png b/public/images/search.png similarity index 100% rename from images/search.png rename to public/images/search.png diff --git a/images/star_empty.png b/public/images/star_empty.png similarity index 100% rename from images/star_empty.png rename to public/images/star_empty.png diff --git a/images/star_filled.png b/public/images/star_filled.png similarity index 100% rename from images/star_filled.png rename to public/images/star_filled.png diff --git a/images/woowacourse_logo.png b/public/images/woowacourse_logo.png similarity index 100% rename from images/woowacourse_logo.png rename to public/images/woowacourse_logo.png diff --git "a/images/\354\234\274\354\225\204\354\225\204\355\226\211\354\204\261\354\235\264.png" "b/public/images/\354\234\274\354\225\204\354\225\204\355\226\211\354\204\261\354\235\264.png" similarity index 100% rename from "images/\354\234\274\354\225\204\354\225\204\355\226\211\354\204\261\354\235\264.png" rename to "public/images/\354\234\274\354\225\204\354\225\204\355\226\211\354\204\261\354\235\264.png" diff --git a/src/apis/movie/api.ts b/src/apis/movie/api.ts index 0bfff099c7..92e2cc9e13 100644 --- a/src/apis/movie/api.ts +++ b/src/apis/movie/api.ts @@ -1,27 +1,11 @@ import { getSearchParamsFromObject } from "../../utils/getSearchParamsFromObject"; import { tmdbFetcher, TmdbPagination } from "../../utils/tmdbFetcher"; - -export interface Movie { - adult: boolean; - backdrop_path: string; - genre_ids: number[]; - id: number; - original_language: string; - original_title: string; - overview: string; - popularity: number; - poster_path: string; - release_date: string; - title: string; - video: boolean; - vote_average: number; - vote_count: number; -} - -export interface PopularMoviesParameter { - language: string; - page: number; -} +import { + Movie, + MovieDetail, + MovieDetailParameter, + PopularMoviesParameter, +} from "./type.ts"; export const getPopularMovies = async ( params: Partial = {}, @@ -31,3 +15,13 @@ export const getPopularMovies = async ( `/movie/popular?${searchParams.toString()}`, ); }; + +export const getMovieDetail = async ({ + movieId, + ...params +}: MovieDetailParameter) => { + const searchParams = getSearchParamsFromObject(params); + return await tmdbFetcher( + `/movie/${movieId}?${searchParams.toString()}`, + ); +}; diff --git a/src/apis/movie/type.ts b/src/apis/movie/type.ts new file mode 100644 index 0000000000..f0e65bf1bf --- /dev/null +++ b/src/apis/movie/type.ts @@ -0,0 +1,72 @@ +export interface Movie { + adult: boolean; + backdrop_path: string; + genre_ids: number[]; + id: number; + original_language: string; + original_title: string; + overview: string; + popularity: number; + poster_path: string; + release_date: string; + title: string; + video: boolean; + vote_average: number; + vote_count: number; +} + +export interface MovieDetail extends Omit { + belongs_to_collection: BelongsToCollection; + budget: number; + genres: Genre[]; + homepage: string; + imdb_id: string; + origin_country: string[]; + production_companies: ProductionCompany[]; + production_countries: ProductionCountry[]; + revenue: number; + runtime: number; + spoken_languages: SpokenLanguage[]; + status: string; + tagline: string; +} + +export interface BelongsToCollection { + id: number; + name: string; + poster_path: string; + backdrop_path: string; +} + +export interface Genre { + id: number; + name: string; +} + +export interface ProductionCompany { + id: number; + logo_path: string; + name: string; + origin_country: string; +} + +export interface ProductionCountry { + iso_3166_1: string; + name: string; +} + +export interface SpokenLanguage { + english_name: string; + iso_639_1: string; + name: string; +} + +export interface PopularMoviesParameter { + language: string; + page: number; +} + +export interface MovieDetailParameter { + language: string; + movieId: number; +} diff --git a/src/apis/rate/api.ts b/src/apis/rate/api.ts new file mode 100644 index 0000000000..13b617ffd2 --- /dev/null +++ b/src/apis/rate/api.ts @@ -0,0 +1,30 @@ +import { CreateMovieRateParameter, GetMovieRateParameter, Rate, UpdateMovieRateParameter } from "./type.ts"; + +const RATE_KEY = "rate"; + +const getRates = (): Record => { + return JSON.parse(localStorage.getItem(RATE_KEY) ?? "{}"); +}; + +export const getRate = async ({ + movieId, +}: GetMovieRateParameter): Promise => { + const rate = getRates()[movieId]; + return { rate: rate ?? null }; +}; + +export const createRate = async ({ + movieId, + rate, +}: CreateMovieRateParameter) => { + const rates = getRates(); + localStorage.setItem(RATE_KEY, JSON.stringify({ ...rates, [movieId]: rate })); +}; + +export const updateRate = async ({ + movieId, + rate, +}: UpdateMovieRateParameter) => { + const rates = getRates(); + localStorage.setItem(RATE_KEY, JSON.stringify({ ...rates, [movieId]: rate })); +}; diff --git a/src/apis/rate/type.ts b/src/apis/rate/type.ts new file mode 100644 index 0000000000..51e8616656 --- /dev/null +++ b/src/apis/rate/type.ts @@ -0,0 +1,14 @@ +export interface GetMovieRateParameter { + movieId: number; +} + +export interface CreateMovieRateParameter { + movieId: number; + rate: number; +} + +export interface UpdateMovieRateParameter extends CreateMovieRateParameter {} + +export interface Rate { + rate: number | null; +} diff --git a/src/apis/search/api.ts b/src/apis/search/api.ts index c1e49b78fe..b9964c64c3 100644 --- a/src/apis/search/api.ts +++ b/src/apis/search/api.ts @@ -1,12 +1,7 @@ import { getSearchParamsFromObject } from "../../utils/getSearchParamsFromObject"; import { tmdbFetcher, TmdbPagination } from "../../utils/tmdbFetcher"; -import { Movie } from "../movie/api"; - -export interface SearchedMoviesParameter { - query: string; - language: string; - page: number; -} +import { Movie } from "../movie/type"; +import { SearchedMoviesParameter } from "./type.ts"; export const getSearchedMovies = async ( params: Partial = {}, diff --git a/src/apis/search/type.ts b/src/apis/search/type.ts new file mode 100644 index 0000000000..eba8edab1c --- /dev/null +++ b/src/apis/search/type.ts @@ -0,0 +1,5 @@ +export interface SearchedMoviesParameter { + query: string; + language: string; + page: number; +} diff --git a/src/constants/rates.ts b/src/constants/rates.ts new file mode 100644 index 0000000000..285bc6548e --- /dev/null +++ b/src/constants/rates.ts @@ -0,0 +1,32 @@ +export const RATES = [ + { + rate: 0, + comment: "별점을 남겨주세요", + score: 0, + }, + { + rate: 1, + comment: "최악이에요", + score: 2, + }, + { + rate: 2, + comment: "별로예요", + score: 4, + }, + { + rate: 3, + comment: "보통이에요", + score: 6, + }, + { + rate: 4, + comment: "재미있어요", + score: 8, + }, + { + rate: 5, + comment: "명작이에요", + score: 10, + }, +]; diff --git a/src/dom/components/Banner.ts b/src/dom/components/Banner.ts new file mode 100644 index 0000000000..271d7f1873 --- /dev/null +++ b/src/dom/components/Banner.ts @@ -0,0 +1,70 @@ +import { Movie } from "../../apis/movie/type"; +import { renderMovieModal } from "./MovieModal.ts"; +import { getMovieDetail } from "../../apis/movie/api.ts"; + +const BANNER_ID = "background-container"; + +let bannerElement: HTMLElement | null = null; + +const createBannerTemplate = (movie: Movie | null) => ` +
+ +
+
+
+ 별점 + ${movie?.vote_average ?? "..."} +
+

${movie?.title ?? "정보를 불러오는 중..."}

+ +
+
+
+`; + +export const renderBanner = ( + parent: HTMLElement, + movie: Movie | null = null, +) => { + if (bannerElement) { + bannerElement.remove(); + } + + parent.insertAdjacentHTML("beforeend", createBannerTemplate(movie)); + bannerElement = document.getElementById(BANNER_ID); + + if (bannerElement && movie) { + bannerElement.style.backgroundImage = `url(${import.meta.env.VITE_TMDB_IMAGE_BASE_URL}/w1280${movie.backdrop_path})`; + } + + const button = bannerElement?.querySelector("button"); + button?.addEventListener("click", async () => { + if (movie?.id == null) { + window.alert("영화 정보를 불러올 수 없습니다."); + return; + } + + try { + const movieDetail = await getMovieDetail({ + movieId: movie.id, + language: "ko-KR", + }); + renderMovieModal(document.body, movieDetail); + } catch { + window.alert("영화 정보를 불러올 수 없습니다."); + } + }); +}; + +export const removeBanner = () => { + bannerElement?.remove(); + bannerElement = null; +}; + +export const hideBanner = () => { + bannerElement?.classList.add("hidden"); +}; + +export const showBanner = () => { + bannerElement?.classList.remove("hidden"); +}; diff --git a/src/dom/components/EmptyContainer.ts b/src/dom/components/EmptyContainer.ts new file mode 100644 index 0000000000..c00ca4e463 --- /dev/null +++ b/src/dom/components/EmptyContainer.ts @@ -0,0 +1,24 @@ +const EMPTY_CONTAINER_ID = "empty-container"; + +let emptyContainer: HTMLElement | null = null; + +const createEmptyContainerTemplate = (message: string) => ` +
+ +

${message}

+
+`; + +export const renderEmptyContainer = (parent: HTMLElement, message: string) => { + if (emptyContainer) { + emptyContainer.remove(); + } + + parent.insertAdjacentHTML("beforeend", createEmptyContainerTemplate(message)); + emptyContainer = document.getElementById(EMPTY_CONTAINER_ID); +}; + +export const removeEmptyContainer = () => { + emptyContainer?.remove(); + emptyContainer = null; +}; diff --git a/src/dom/components/ErrorContainer.ts b/src/dom/components/ErrorContainer.ts new file mode 100644 index 0000000000..e142d0a2c3 --- /dev/null +++ b/src/dom/components/ErrorContainer.ts @@ -0,0 +1,32 @@ +const ERROR_CONTAINER_ID = "error-container"; +const RETRY_BUTTON_ID = "retry-button"; + +let errorContainer: HTMLElement | null = null; + +const createErrorContainerTemplate = (message: string) => ` +
+ +

${message}

+ +
+`; + +export const renderErrorContainer = (parent: HTMLElement, message: string) => { + if (errorContainer) { + errorContainer.remove(); + } + + parent.insertAdjacentHTML("beforeend", createErrorContainerTemplate(message)); + errorContainer = document.getElementById(ERROR_CONTAINER_ID); + + errorContainer + ?.querySelector(`#${RETRY_BUTTON_ID}`) + ?.addEventListener("click", () => { + window.location.reload(); + }); +}; + +export const removeErrorContainer = () => { + errorContainer?.remove(); + errorContainer = null; +}; diff --git a/src/dom/components/MainSeeMoreButton.ts b/src/dom/components/MainSeeMoreButton.ts new file mode 100644 index 0000000000..4f3e8808a3 --- /dev/null +++ b/src/dom/components/MainSeeMoreButton.ts @@ -0,0 +1,24 @@ +const MAIN_SEE_MORE_BUTTON_ID = "main-see-more-button"; + +let mainSeeMoreButton: HTMLElement | null = null; + +const createMainSeeMoreButtonTemplate = () => ` + +`; + +export const renderMainSeeMoreButton = (parent: HTMLElement, onClick: () => void) => { + if (mainSeeMoreButton) { + mainSeeMoreButton.remove(); + } + + parent.insertAdjacentHTML("beforeend", createMainSeeMoreButtonTemplate()); + mainSeeMoreButton = document.getElementById(MAIN_SEE_MORE_BUTTON_ID); + mainSeeMoreButton?.addEventListener("click", onClick); +}; + +export const removeMainSeeMoreButton = () => { + mainSeeMoreButton?.remove(); + mainSeeMoreButton = null; +}; diff --git a/src/dom/components/MovieModal.ts b/src/dom/components/MovieModal.ts new file mode 100644 index 0000000000..a22d9a3fdd --- /dev/null +++ b/src/dom/components/MovieModal.ts @@ -0,0 +1,96 @@ +import { MovieDetail } from "../../apis/movie/type"; +import { renderMyRate } from "./MyRate.ts"; + +const MODAL_ID = "modal-dialog"; +const MY_RATE_CONTAINER_ID = "my-rate-container"; + +let modalElement: HTMLDialogElement | null = null; + +// TODO: 예외 메시지 점검 (애초에 tmdb에 정보가 없으면 null로 오는 듯) +const createMovieModalTemplate = (movie: MovieDetail | null) => ` + + + + +`; + +// TODO: renderMovieModalLoading? +export const renderMovieModal = ( + parent: HTMLElement, + movie: MovieDetail | null = null, +) => { + if (modalElement) { + // TODO: 매번 remove하지 않으려면 상태 기반으로 데이터가 변경되도록? + modalElement.remove(); + } + + parent.insertAdjacentHTML("beforeend", createMovieModalTemplate(movie)); + modalElement = document.getElementById(MODAL_ID) as HTMLDialogElement | null; + + const myRateContainer = document.getElementById(MY_RATE_CONTAINER_ID); + if (myRateContainer) { + renderMyRate(myRateContainer, movie?.id ?? -1); + } + + modalElement?.showModal(); + document.body.classList.add("modal-open"); + + modalElement?.addEventListener("cancel", () => { + hideMovieModal(); + }); + + modalElement?.addEventListener("click", (e) => { + if (e.target === e.currentTarget) { + hideMovieModal(); + } + }); + + const closeModalButton = modalElement?.querySelector("button"); + closeModalButton?.addEventListener("click", () => { + hideMovieModal(); + }); +}; + +export const removeMovieModal = () => { + hideMovieModal(); + modalElement?.remove(); + modalElement = null; +}; + +export const hideMovieModal = () => { + modalElement?.remove(); + document.body.classList.remove("modal-open"); +}; + +export const showMovieModal = () => { + modalElement?.showModal(); +}; diff --git a/src/dom/components/MyRate.ts b/src/dom/components/MyRate.ts new file mode 100644 index 0000000000..d9471d7769 --- /dev/null +++ b/src/dom/components/MyRate.ts @@ -0,0 +1,72 @@ +import { createRate, getRate, updateRate } from "../../apis/rate/api.ts"; +import { RATES } from "../../constants/rates.ts"; +import { renderRateButtons } from "./RateButton.ts"; + +const MY_RATE_ID = "my-rate"; +const RATE_BUTTON_CONTAINER_ID = "rate-button-container"; + +let myRateElement: HTMLElement | null = null; + +// TODO: (typeof RATES)[number]["rate"]로 타입을 엄격히 검사할 필요가 있을까? +const createMyRateTemplate = (userRate: number) => { + const rateConfig = RATES.find(({ rate }) => rate === userRate); + + return ` +
+

내 별점

+
+
+ ${rateConfig?.comment} + (${rateConfig?.score}/10) +
+
+`; +}; + +export const renderMyRate = async (parent: HTMLElement, movieId: number) => { + if (myRateElement) { + myRateElement.remove(); + } + + // TODO: 에러 핸들링 필요한가 + const response = await getRate({ movieId }); + const rate = response?.rate ?? 0; + + parent.insertAdjacentHTML("beforeend", createMyRateTemplate(rate)); + myRateElement = document.getElementById(MY_RATE_ID); + + const rateButtonContainer = document.getElementById(RATE_BUTTON_CONTAINER_ID); + if (rateButtonContainer) { + renderRateButtons(rateButtonContainer, "filled", rate); + renderRateButtons(rateButtonContainer, "empty", 5 - rate); + } + + // TODO: 이벤트 핸들러, 분리해야 하나? + myRateElement?.addEventListener("click", async (e) => { + if (e.target instanceof HTMLElement) { + const rateButtonContainer = document.getElementById( + RATE_BUTTON_CONTAINER_ID, + ) as HTMLElement; + const clickedButton = e.target.closest("button"); + + const buttonIndex = Array.from(rateButtonContainer.children).findIndex( + (element) => element === clickedButton, + ); + + if (buttonIndex === -1) return; + + // TODO: 에러 핸들링 필요한가 + 저장되었습니다 토스트? + if (rate === 0) { + await createRate({ movieId, rate: buttonIndex + 1 }); + } else { + await updateRate({ movieId, rate: buttonIndex + 1 }); + } + await renderMyRate(parent, movieId); + } + }); +}; + +export const removeMyRate = () => { + myRateElement?.remove(); + myRateElement = null; +}; diff --git a/src/dom/components/PopularThumbnailList.ts b/src/dom/components/PopularThumbnailList.ts new file mode 100644 index 0000000000..78a17290f9 --- /dev/null +++ b/src/dom/components/PopularThumbnailList.ts @@ -0,0 +1,47 @@ +import { Movie } from "../../apis/movie/type"; +import { + renderMovieItems, + renderMovieItemsLoading, +} from "../shared/MovieItem.ts"; + +const POPULAR_THUMBNAIL_LIST_ID = "popular-thumbnail-list"; + +let popularThumbnailList: HTMLElement | null = null; + +const createPopularThumbnailListTemplate = () => ` +
    +`; + +export const renderPopularThumbnailLoading = (parent: HTMLElement) => { + if (popularThumbnailList) { + popularThumbnailList.remove(); + } + + parent.insertAdjacentHTML("beforeend", createPopularThumbnailListTemplate()); + popularThumbnailList = document.getElementById(POPULAR_THUMBNAIL_LIST_ID); + + if (popularThumbnailList) { + renderMovieItemsLoading(popularThumbnailList); + } +}; + +export const renderPopularThumbnailList = ( + parent: HTMLElement, + movies: Movie[], +) => { + if (popularThumbnailList) { + popularThumbnailList.remove(); + } + + parent.insertAdjacentHTML("beforeend", createPopularThumbnailListTemplate()); + popularThumbnailList = document.getElementById(POPULAR_THUMBNAIL_LIST_ID); + + if (popularThumbnailList && movies.length > 0) { + renderMovieItems(popularThumbnailList, movies); + } +}; + +export const removePopularThumbnailList = () => { + popularThumbnailList?.remove(); + popularThumbnailList = null; +}; diff --git a/src/dom/components/RateButton.ts b/src/dom/components/RateButton.ts new file mode 100644 index 0000000000..11b29b1368 --- /dev/null +++ b/src/dom/components/RateButton.ts @@ -0,0 +1,19 @@ +const createRateButtonTemplate = (type: "filled" | "empty") => { + return ` + +`; +}; + +export const renderRateButtons = ( + parent: HTMLElement, + type: "filled" | "empty", + count: number = 1, +) => { + const itemsHTML = Array.from({ length: count }) + .map(() => createRateButtonTemplate(type)) + .join(""); + + parent.insertAdjacentHTML("beforeend", itemsHTML); +}; diff --git a/src/dom/components/SearchSeeMoreButton.ts b/src/dom/components/SearchSeeMoreButton.ts new file mode 100644 index 0000000000..4b9fe9a17d --- /dev/null +++ b/src/dom/components/SearchSeeMoreButton.ts @@ -0,0 +1,24 @@ +const SEARCH_SEE_MORE_BUTTON_ID = "search-see-more-button"; + +let searchSeeMoreButton: HTMLElement | null = null; + +const createSearchSeeMoreButtonTemplate = () => ` + +`; + +export const renderSearchSeeMoreButton = (parent: HTMLElement, onClick: () => void) => { + if (searchSeeMoreButton) { + searchSeeMoreButton.remove(); + } + + parent.insertAdjacentHTML("beforeend", createSearchSeeMoreButtonTemplate()); + searchSeeMoreButton = document.getElementById(SEARCH_SEE_MORE_BUTTON_ID); + searchSeeMoreButton?.addEventListener("click", onClick); +}; + +export const removeSearchSeeMoreButton = () => { + searchSeeMoreButton?.remove(); + searchSeeMoreButton = null; +}; diff --git a/src/dom/components/SearchThumbnailList.ts b/src/dom/components/SearchThumbnailList.ts new file mode 100644 index 0000000000..e08c53a8c2 --- /dev/null +++ b/src/dom/components/SearchThumbnailList.ts @@ -0,0 +1,44 @@ +import { Movie } from "../../apis/movie/type"; +import { renderMovieItems, renderMovieItemsLoading } from "../shared/MovieItem.ts"; + +const SEARCH_THUMBNAIL_LIST_ID = "search-thumbnail-list"; + +let searchThumbnailList: HTMLElement | null = null; + +const createSearchThumbnailListTemplate = () => ` +
      +`; + +export const renderSearchThumbnailLoading = (parent: HTMLElement) => { + if (searchThumbnailList) { + searchThumbnailList.remove(); + } + + parent.insertAdjacentHTML("beforeend", createSearchThumbnailListTemplate()); + searchThumbnailList = document.getElementById(SEARCH_THUMBNAIL_LIST_ID); + + if (searchThumbnailList) { + renderMovieItemsLoading(searchThumbnailList); + } +}; + +export const renderSearchThumbnailList = ( + parent: HTMLElement, + movies: Movie[], +) => { + if (searchThumbnailList) { + searchThumbnailList.remove(); + } + + parent.insertAdjacentHTML("beforeend", createSearchThumbnailListTemplate()); + searchThumbnailList = document.getElementById(SEARCH_THUMBNAIL_LIST_ID); + + if (searchThumbnailList && movies.length > 0) { + renderMovieItems(searchThumbnailList, movies); + } +}; + +export const removeSearchThumbnailList = () => { + searchThumbnailList?.remove(); + searchThumbnailList = null; +}; diff --git a/src/dom/compositions/Home.ts b/src/dom/compositions/Home.ts new file mode 100644 index 0000000000..eb284238b4 --- /dev/null +++ b/src/dom/compositions/Home.ts @@ -0,0 +1,142 @@ +import { Movie } from "../../apis/movie/type.ts"; +import { handleMainSeeMore } from "../eventHandler/handleSeeMore.ts"; +import { + removeEmptyContainer, + renderEmptyContainer, +} from "../components/EmptyContainer.ts"; +import { + removeErrorContainer, + renderErrorContainer, +} from "../components/ErrorContainer.ts"; +import { + removePopularThumbnailList, + renderPopularThumbnailList, + renderPopularThumbnailLoading, +} from "../components/PopularThumbnailList.ts"; +import { + removeMovieItemsLoading, + renderMovieItems, +} from "../shared/MovieItem.ts"; +import { removeBanner, renderBanner } from "../components/Banner.ts"; +import { removeSearch } from "./Search.ts"; + +const HOME_OBSERVER_TARGET_ID = "home-observer-target"; +let homeObserver: IntersectionObserver | null = null; +let homeObserverTarget: HTMLElement | null = null; + +export const renderHome = (isLastPage: boolean, movies: Movie[]) => { + removeHome(); + removeSearch(); + + const header = document.querySelector("header"); + if (header) { + renderBanner(header, movies[0]); + } + + const resultSection = document.getElementById("result-section"); + if (!resultSection) return; + + renderPopularThumbnailList(resultSection, movies); + + if (!isLastPage) { + observeTarget(resultSection, () => { + handleMainSeeMore(); + }); + } +}; + +export const renderHomeLoading = () => { + removeHome(); + removeSearch(); + + const header = document.querySelector("header"); + if (header) { + renderBanner(header); + } + + const resultSection = document.getElementById("result-section"); + if (resultSection) { + renderPopularThumbnailLoading(resultSection); + } +}; + +export const renderHomeError = (errorMessage?: string) => { + removeHome(); + removeSearch(); + + const resultSection = document.getElementById("result-section"); + if (resultSection) { + renderErrorContainer( + resultSection, + errorMessage || "🚨문제가 발생했습니다.🚨", + ); + } +}; + +export const renderHomeEmpty = () => { + removeHome(); + removeSearch(); + + const resultSection = document.getElementById("result-section"); + if (resultSection) { + renderEmptyContainer(resultSection, "검색 결과가 없습니다."); + } +}; + +export const appendPopularMovies = (isLastPage: boolean, movies: Movie[]) => { + const resultSection = document.getElementById("result-section"); + const popularThumbnailList = document.getElementById( + "popular-thumbnail-list", + ); + if (!resultSection || !popularThumbnailList) return; + + removeObserverTarget(); + removeMovieItemsLoading(popularThumbnailList as HTMLElement); + renderMovieItems(popularThumbnailList as HTMLElement, movies); + + if (!isLastPage) { + observeTarget(resultSection, () => { + handleMainSeeMore(); + }); + } +}; + +export const removeHome = () => { + homeObserver?.disconnect(); + homeObserver = null; + removeObserverTarget(); + removeBanner(); + removePopularThumbnailList(); + removeErrorContainer(); + removeEmptyContainer(); +}; + +const observeTarget = (parent: HTMLElement, onIntersect: () => void) => { + homeObserver?.disconnect(); + + parent.insertAdjacentHTML( + "beforeend", + `
      `, + ); + homeObserverTarget = document.getElementById(HOME_OBSERVER_TARGET_ID); + if (!homeObserverTarget) return; + + homeObserver = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + homeObserver?.disconnect(); + onIntersect(); + } + }, + { + rootMargin: "400px", + threshold: 0.1, + }, + ); + homeObserver.observe(homeObserverTarget); +}; + +const removeObserverTarget = () => { + homeObserverTarget?.remove(); + homeObserverTarget = null; +}; diff --git a/src/dom/compositions/Search.ts b/src/dom/compositions/Search.ts new file mode 100644 index 0000000000..a21a8be7d5 --- /dev/null +++ b/src/dom/compositions/Search.ts @@ -0,0 +1,121 @@ +import { Movie } from "../../apis/movie/type.ts"; +import { handleSearchSeeMore } from "../eventHandler/handleSeeMore.ts"; +import { removeEmptyContainer, renderEmptyContainer } from "../components/EmptyContainer.ts"; +import { removeErrorContainer, renderErrorContainer } from "../components/ErrorContainer.ts"; +import { removeSearchThumbnailList, renderSearchThumbnailList, renderSearchThumbnailLoading } from "../components/SearchThumbnailList.ts"; +import { removeMovieItemsLoading, renderMovieItems } from "../shared/MovieItem.ts"; +import { removeHome } from "./Home.ts"; + +const SEARCH_OBSERVER_TARGET_ID = "search-observer-target"; +let searchObserver: IntersectionObserver | null = null; +let searchObserverTarget: HTMLElement | null = null; + +export const renderSearch = (isLastPage: boolean, movies: Movie[]) => { + removeHome(); + removeSearch(); + + const resultSection = document.getElementById("result-section"); + if (!resultSection) return; + + renderSearchThumbnailList(resultSection, movies); + + if (!isLastPage) { + observeTarget(resultSection, () => { + handleSearchSeeMore(); + }); + } +}; + +export const renderSearchLoading = (keyword: string) => { + removeHome(); + removeSearch(); + + const resultSection = document.getElementById("result-section"); + const subTitle = document.getElementById("sub-title"); + + if (resultSection) { + resultSection.classList.add("result-section"); + renderSearchThumbnailLoading(resultSection); + } + if (subTitle) { + subTitle.innerText = `"${keyword}" 검색 결과`; + } +}; + +export const renderSearchError = (errorMessage?: string) => { + removeHome(); + removeSearch(); + + const resultSection = document.getElementById("result-section"); + if (resultSection) { + renderErrorContainer( + resultSection, + errorMessage || "🚨문제가 발생했습니다.🚨", + ); + } +}; + +export const renderSearchEmpty = () => { + removeHome(); + removeSearch(); + + const resultSection = document.getElementById("result-section"); + if (resultSection) { + renderEmptyContainer(resultSection, "검색 결과가 없습니다."); + } +}; + +export const appendSearchedMovies = (isLastPage: boolean, movies: Movie[]) => { + const resultSection = document.getElementById("result-section"); + const searchThumbnailList = document.getElementById("search-thumbnail-list"); + if (!resultSection || !searchThumbnailList) return; + + removeObserverTarget(); + removeMovieItemsLoading(searchThumbnailList as HTMLElement); + renderMovieItems(searchThumbnailList as HTMLElement, movies); + + if (!isLastPage) { + observeTarget(resultSection, () => { + handleSearchSeeMore(); + }); + } +}; + +export const removeSearch = () => { + searchObserver?.disconnect(); + searchObserver = null; + removeObserverTarget(); + removeSearchThumbnailList(); + removeErrorContainer(); + removeEmptyContainer(); +}; + +const observeTarget = (parent: HTMLElement, onIntersect: () => void) => { + searchObserver?.disconnect(); + + parent.insertAdjacentHTML( + "beforeend", + `
      `, + ); + searchObserverTarget = document.getElementById(SEARCH_OBSERVER_TARGET_ID); + if (!searchObserverTarget) return; + + searchObserver = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + searchObserver?.disconnect(); + onIntersect(); + } + }, + { + rootMargin: "400px", + threshold: 0.1, + }, + ); + searchObserver.observe(searchObserverTarget); +}; + +const removeObserverTarget = () => { + searchObserverTarget?.remove(); + searchObserverTarget = null; +}; diff --git a/src/dom/eventHandler/handleMovieItemClick.ts b/src/dom/eventHandler/handleMovieItemClick.ts new file mode 100644 index 0000000000..0138c278e6 --- /dev/null +++ b/src/dom/eventHandler/handleMovieItemClick.ts @@ -0,0 +1,27 @@ +import { getMovieDetail } from "../../apis/movie/api.ts"; +import { renderMovieModal } from "../components/MovieModal.ts"; + +export const handleMovieItemClick = async (e: MouseEvent) => { + if (e.target instanceof HTMLElement) { + const li = e.target.closest("li"); + if (!li) return; + + const movieId = li.id; + + if (movieId == null) { + alert("영화 정보를 불러올 수 없습니다."); + return; + } + + try { + const movieDetail = await getMovieDetail({ + movieId: Number(movieId), + language: "ko-KR", + }); + + renderMovieModal(document.body, movieDetail); + } catch { + alert("영화 정보를 불러올 수 없습니다."); + } + } +}; diff --git a/src/dom/eventHandler/handleMovieSearch.ts b/src/dom/eventHandler/handleMovieSearch.ts index 42ecd6eb4d..b774439127 100644 --- a/src/dom/eventHandler/handleMovieSearch.ts +++ b/src/dom/eventHandler/handleMovieSearch.ts @@ -1,6 +1,4 @@ -import { renderSearchUI } from "../render/renderSearchUI"; - -export const handleMovieSearch = async (keyword: string) => { +export const handleMovieSearch = (keyword: string) => { if (keyword.trim() === "") { const hasKeyword = new URLSearchParams(window.location.search).has( "keyword", @@ -11,18 +9,12 @@ export const handleMovieSearch = async (keyword: string) => { return; } - const thumbnailListElement = document.getElementById( - "search-thumbnail-list", - ) as HTMLUListElement; - const url = new URL(window.location.href); const params = url.searchParams; params.set("keyword", keyword); - params.set("page", String(1)); - url.search = params.toString(); - window.history.pushState({}, "", url.toString()); + sessionStorage.setItem("page", "1"); - thumbnailListElement.innerHTML = ""; - await renderSearchUI(keyword); + url.search = params.toString(); + window.location.href = url.toString(); }; diff --git a/src/dom/eventHandler/handleSeeMore.ts b/src/dom/eventHandler/handleSeeMore.ts index 3506f4de09..f6af732e35 100644 --- a/src/dom/eventHandler/handleSeeMore.ts +++ b/src/dom/eventHandler/handleSeeMore.ts @@ -1,82 +1,16 @@ -import { getPopularMovies } from "../../apis/movie/api"; -import { getSearchedMovies } from "../../apis/search/api"; -import TMDBError from "../../TMDBError"; -import { renderResultSectionContent } from "../render/renderResultSectionContent"; -import { renderThumbnailList } from "../render/renderThumbnailList"; +import { renderSearchPage } from "../../pages/search.ts"; +import { renderHomePage } from "../../pages/home.ts"; export const handleMainSeeMore = async () => { - const mainThumbnailList = document.getElementById("main-thumbnail-list"); - const url = new URL(window.location.href); - const params = url.searchParams; - const prevPage = Number(params.get("page") || 1); + const prevPage = Number(sessionStorage.getItem("page") || 1); + sessionStorage.setItem("page", String(prevPage + 1)); - params.set("page", String(prevPage + 1)); - url.search = params.toString(); - window.history.pushState({}, "", url.toString()); - - try { - const popularMovies = await getPopularMovies({ - page: prevPage + 1, - language: "ko-KR", - }); - const isLastPage = popularMovies.page === popularMovies.total_pages; - const movies = popularMovies.results; - - renderThumbnailList({ - movies, - thumbnailListElement: mainThumbnailList, - }); - - renderResultSectionContent({ - isLoading: false, - isError: false, - isLastPage, - movies, - }); - } catch (error) { - let errorMessage = "알 수 없는 에러가 발생했습니다."; - if (error instanceof TMDBError) { - errorMessage = "TMDB에서 데이터를 불러오는 중 에러가 발생했습니다"; - } - window.alert(errorMessage); - } + await renderHomePage("append"); }; -export const handleSearchSeeMore = async (keyword: string) => { - const mainThumbnailList = document.getElementById("search-thumbnail-list"); - const url = new URL(window.location.href); - const params = url.searchParams; - const prevPage = Number(params.get("page") || 1); - - params.set("page", String(prevPage + 1)); - url.search = params.toString(); - window.history.pushState({}, "", url.toString()); - - try { - const searchResult = await getSearchedMovies({ - query: keyword, - page: prevPage + 1, - language: "ko-KR", - }); - const isLastPage = searchResult.page === searchResult.total_pages; - const movies = searchResult.results; - - renderThumbnailList({ - movies, - thumbnailListElement: mainThumbnailList, - }); +export const handleSearchSeeMore = async () => { + const prevPage = Number(sessionStorage.getItem("page") || 1); + sessionStorage.setItem("page", String(prevPage + 1)); - renderResultSectionContent({ - isLoading: false, - isError: false, - isLastPage, - movies, - }); - } catch (error) { - let errorMessage = "알 수 없는 에러가 발생했습니다."; - if (error instanceof TMDBError) { - errorMessage = "TMDB에서 데이터를 불러오는 중 에러가 발생했습니다"; - } - window.alert(errorMessage); - } + await renderSearchPage("append"); }; diff --git a/src/dom/render/renderBanner.ts b/src/dom/render/renderBanner.ts deleted file mode 100644 index d97b9755ca..0000000000 --- a/src/dom/render/renderBanner.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Movie } from "../../apis/movie/api"; - -interface RenderBannerProps { - movie: Movie; -} - -export const renderBanner = ({ movie }: RenderBannerProps) => { - const banner = document.getElementById( - "background-container", - ) as HTMLDivElement; - const bannerTitle = document.querySelector( - "#background-container h3", - ) as HTMLHeadingElement; - const bannerRate = document.querySelector( - "#background-container span", - ) as HTMLSpanElement; - - if (banner && bannerTitle && bannerRate) { - banner.style.backgroundImage = `url(${import.meta.env.VITE_TMDB_IMAGE_BASE_URL}/w1280${movie.backdrop_path})`; - bannerTitle.textContent = movie.title; - bannerRate.textContent = String(movie.vote_average); - } -}; diff --git a/src/dom/render/renderLoadingUI.ts b/src/dom/render/renderLoadingUI.ts deleted file mode 100644 index 436fe94f06..0000000000 --- a/src/dom/render/renderLoadingUI.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { renderResultSectionContent } from "./renderResultSectionContent"; - -export const renderLoadingUI = () => { - renderResultSectionContent({ - isLoading: true, - isError: false, - isLastPage: true, - movies: [], - }); -}; diff --git a/src/dom/render/renderMainUI.ts b/src/dom/render/renderMainUI.ts deleted file mode 100644 index 76f1fb9a5d..0000000000 --- a/src/dom/render/renderMainUI.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { getPopularMovies, Movie } from "../../apis/movie/api"; -import TMDBError from "../../TMDBError"; -import { renderBanner } from "./renderBanner"; -import { renderResultSectionContent } from "./renderResultSectionContent"; -import { renderThumbnailList } from "./renderThumbnailList"; - -export const renderMainUI = async () => { - let isError = false; - let isLastPage = true; - let movies: Movie[] = []; - let errorMessage = ""; - - try { - const thumbnailListElement = document.getElementById("main-thumbnail-list"); - const popularMovies = await getPopularMovies({ language: "ko-KR" }); - isLastPage = popularMovies.page === popularMovies.total_pages; - movies = popularMovies.results; - renderBanner({ movie: movies[0] }); - renderThumbnailList({ movies, thumbnailListElement }); - } catch (error) { - isError = true; - errorMessage = "🚨알 수 없는 에러가 발생했습니다.🚨"; - if (error instanceof TMDBError) { - errorMessage = "🚨TMDB에서 데이터를 불러오는 중 에러가 발생했습니다🚨"; - } - } finally { - renderResultSectionContent({ - isLoading: false, - isError, - isLastPage, - errorMessage, - movies, - }); - } -}; diff --git a/src/dom/render/renderResultSectionContent.ts b/src/dom/render/renderResultSectionContent.ts deleted file mode 100644 index c1ee8f7336..0000000000 --- a/src/dom/render/renderResultSectionContent.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Movie } from "../../apis/movie/api"; - -interface RenderResultSectionContentProps { - isLoading: boolean; - isError: boolean; - isLastPage?: boolean; - errorMessage?: string; - movies: Movie[]; -} - -export const renderResultSectionContent = ({ - isLoading, - isError, - errorMessage, - isLastPage = true, - movies, -}: RenderResultSectionContentProps) => { - const skeletonList = document.getElementById("skeleton-list"); - const errorContainer = document.getElementById("error-container"); - const emptyContainer = document.getElementById("empty-container"); - - const errorMessageContent = document.querySelector( - "#error-container p", - ) as HTMLParagraphElement; - - const mainThumbnailList = document.getElementById("main-thumbnail-list"); - const mainSeeMoreButton = document.getElementById("main-see-more-button"); - - const banner = document.getElementById("background-container"); - const resultSection = document.getElementById("result-section"); - const subTitle = document.getElementById("sub-title"); - const searchThumbnailList = document.getElementById("search-thumbnail-list"); - const searchSeeMoreButton = document.getElementById("search-see-more-button"); - - const url = new URL(window.location.href); - const params = url.searchParams; - const keyword = params.get("keyword"); - const type = keyword ? "search" : "main"; - - skeletonList?.classList.add("hidden"); - errorContainer?.classList.add("hidden"); - emptyContainer?.classList.add("hidden"); - - errorMessageContent?.classList.add("hidden"); - - mainThumbnailList?.classList.add("hidden"); - mainSeeMoreButton?.classList.add("hidden"); - searchThumbnailList?.classList.add("hidden"); - searchSeeMoreButton?.classList.add("hidden"); - - if (isLoading && type === "main") { - skeletonList?.classList.remove("hidden"); - return; - } - - if (isLoading && type === "search" && subTitle) { - banner?.classList.add("hidden"); - resultSection?.classList.add("result-section"); - subTitle.innerText = `"${keyword}" 검색 결과`; - skeletonList?.classList.remove("hidden"); - return; - } - - if (isError) { - errorContainer?.classList.remove("hidden"); - errorMessageContent.innerText = errorMessage || "🚨문제가 발생했습니다.🚨"; - return; - } - - if (movies.length > 0 && type === "main") { - mainThumbnailList?.classList.remove("hidden"); - if (!isLastPage) mainSeeMoreButton?.classList.remove("hidden"); - return; - } - - if (movies.length > 0 && type === "search") { - searchThumbnailList?.classList.remove("hidden"); - if (!isLastPage) searchSeeMoreButton?.classList.remove("hidden"); - return; - } - - emptyContainer?.classList.remove("hidden"); -}; diff --git a/src/dom/render/renderSearchUI.ts b/src/dom/render/renderSearchUI.ts deleted file mode 100644 index caa8e2fe11..0000000000 --- a/src/dom/render/renderSearchUI.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Movie } from "../../apis/movie/api"; -import { getSearchedMovies } from "../../apis/search/api"; -import TMDBError from "../../TMDBError"; -import { renderResultSectionContent } from "./renderResultSectionContent"; -import { renderThumbnailList } from "./renderThumbnailList"; -import { renderLoadingUI } from "./renderLoadingUI.ts"; - -export const renderSearchUI = async (keyword: string) => { - const searchInput = document.getElementById( - "search-input", - ) as HTMLInputElement; - const thumbnailListElement = document.getElementById("search-thumbnail-list"); - searchInput.value = keyword; - - let isError = false; - let isLastPage = true; - let movies: Movie[] = []; - let errorMessage = ""; - - if (searchInput?.value.trim() === "") return; - - try { - renderLoadingUI(); - - const searchResult = await getSearchedMovies({ - query: keyword, - language: "ko-KR", - page: 1, - }); - - isLastPage = searchResult.page === searchResult.total_pages; - movies = searchResult.results; - renderThumbnailList({ movies, thumbnailListElement }); - } catch (error) { - isError = true; - errorMessage = "🚨알 수 없는 에러가 발생했습니다.🚨"; - if (error instanceof TMDBError) { - errorMessage = "🚨TMDB에서 데이터를 불러오는 중 에러가 발생했습니다🚨"; - } - } finally { - renderResultSectionContent({ - isLoading: false, - isError, - errorMessage, - movies, - isLastPage, - }); - } -}; diff --git a/src/dom/render/renderThumbnailList.ts b/src/dom/render/renderThumbnailList.ts deleted file mode 100644 index ee1e2790d7..0000000000 --- a/src/dom/render/renderThumbnailList.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {Movie} from '../../apis/movie/api'; - -interface RenderThumbnailListProps { - movies: Movie[]; - thumbnailListElement: HTMLElement | null; -} - -export const renderThumbnailList = ({ - movies, - thumbnailListElement, -}: RenderThumbnailListProps) => { - if (thumbnailListElement) { - const lis = movies.map( - (movie) => `
    • -
      - ${movie.title} 포스터 -
      -

      - ${movie.vote_average} -

      - ${movie.title} -
      -
      -
    • `, - ); - thumbnailListElement.insertAdjacentHTML('beforeend', lis.join('')); - } -}; diff --git a/src/dom/shared/MovieItem.ts b/src/dom/shared/MovieItem.ts new file mode 100644 index 0000000000..2850d68478 --- /dev/null +++ b/src/dom/shared/MovieItem.ts @@ -0,0 +1,55 @@ +import { Movie } from "../../apis/movie/type"; +import { handleMovieItemClick } from "../eventHandler/handleMovieItemClick.ts"; + +const createMovieItemTemplate = (movie: Movie) => ` +
    • +
      + ${movie.title} 포스터 +
      +

      + 별점 + ${movie.vote_average} +

      + ${movie.title} +
      +
      +
    • +`; + +const createMovieItemSkeletonTemplate = () => ` +
    • +
      +
      +
      +

      +

      +
      +
      +
    • +`; + +export const renderMovieItems = (parent: HTMLElement, movies: Movie[]) => { + const itemsHTML = movies.map(createMovieItemTemplate).join(""); + parent.insertAdjacentHTML("beforeend", itemsHTML); + parent.addEventListener("click", handleMovieItemClick); +}; + +export const renderMovieItemsLoading = ( + parent: HTMLElement, + count: number = 20, +) => { + const skeletonsHTML = Array.from( + { length: count }, + createMovieItemSkeletonTemplate, + ).join(""); + parent.insertAdjacentHTML("beforeend", skeletonsHTML); +}; + +export const removeMovieItemsLoading = (parent: HTMLElement) => { + const skeletons = parent.querySelectorAll(".skeleton"); + skeletons.forEach((skeleton) => skeleton.remove()); +}; diff --git a/src/main.ts b/src/main.ts index bb08e30aba..acddcb66e7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,59 +1,44 @@ import { handleMovieSearch } from "./dom/eventHandler/handleMovieSearch"; -import { - handleMainSeeMore, - handleSearchSeeMore, -} from "./dom/eventHandler/handleSeeMore"; -import { renderLoadingUI } from "./dom/render/renderLoadingUI.ts"; -import { renderMainUI } from "./dom/render/renderMainUI"; -import { renderSearchUI } from "./dom/render/renderSearchUI"; - -const logo = document.getElementById("logo"); -const searchInput = document.getElementById( - "search-input", -) as HTMLInputElement | null; -const searchButton = document.getElementById("search-button"); -const mainSeeMoreButton = document.getElementById("main-see-more-button"); -const searchSeeMoreButton = document.getElementById("search-see-more-button"); - -if (logo) { - logo.addEventListener("click", () => { - window.location.href = import.meta.env.BASE_URL; - }); -} - -if (searchInput && searchButton) { - searchButton.addEventListener("click", () => - handleMovieSearch(searchInput.value), - ); - - searchInput.addEventListener("keydown", (e) => { - if (e.key === "Enter") handleMovieSearch(searchInput.value); - }); -} - -if (mainSeeMoreButton) { - mainSeeMoreButton.addEventListener("click", () => { - handleMainSeeMore(); - }); -} - -if (searchSeeMoreButton && searchInput) { - searchSeeMoreButton.addEventListener("click", () => { - handleSearchSeeMore(searchInput.value); - }); -} - -const render = async () => { - renderLoadingUI(); +import { renderHomePage } from "./pages/home.ts"; +import { renderSearchPage } from "./pages/search.ts"; +const main = async () => { const url = new URL(window.location.href); const params = url.searchParams; const keyword = params.get("keyword"); + + sessionStorage.setItem("page", "1"); + addEventListener(); if (keyword) { - await renderSearchUI(keyword); + await renderSearchPage("init"); } else { - await renderMainUI(); + await renderHomePage("init"); + } +}; + +const addEventListener = () => { + // TODO: 헤더 컴포넌트를 분리, 이벤트 리스너를 컴포넌트 책임으로 변경 + const logo = document.getElementById("logo"); + const searchInput = document.getElementById( + "search-input", + ) as HTMLInputElement | null; + const searchButton = document.getElementById("search-button"); + + if (logo) { + logo.addEventListener("click", () => { + window.location.href = import.meta.env.BASE_URL; + }); + } + + if (searchInput && searchButton) { + searchButton.addEventListener("click", () => + handleMovieSearch(searchInput.value), + ); + + searchInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") handleMovieSearch(searchInput.value); + }); } }; -await render(); +await main(); diff --git a/src/pages/home.ts b/src/pages/home.ts new file mode 100644 index 0000000000..6f59403f13 --- /dev/null +++ b/src/pages/home.ts @@ -0,0 +1,56 @@ +import { getPopularMovies } from "../apis/movie/api.ts"; +import { Movie } from "../apis/movie/type.ts"; +import TMDBError from "../TMDBError.ts"; +import { + appendPopularMovies, + renderHome, + renderHomeEmpty, + renderHomeError, + renderHomeLoading, +} from "../dom/compositions/Home.ts"; + +export const renderHomePage = async (type: "init" | "append") => { + let isError = false; + let isLastPage = true; + let movies: Movie[] = []; + let errorMessage = ""; + + const page = Number(sessionStorage.getItem("page") || 1); + + try { + if (type === "init") { + renderHomeLoading(); + } + + const popularMovies = await getPopularMovies({ + language: "ko-KR", + page, + }); + isLastPage = popularMovies.page === popularMovies.total_pages; + movies = popularMovies.results; + } catch (error) { + isError = true; + errorMessage = "🚨알 수 없는 에러가 발생했습니다.🚨"; + if (error instanceof TMDBError) { + errorMessage = "🚨TMDB에서 데이터를 불러오는 중 에러가 발생했습니다🚨"; + } + } finally { + if (type === "init") { + if (isError) { + renderHomeError(errorMessage); + } else if (movies.length === 0) { + renderHomeEmpty(); + } else if (type === "init") { + renderHome(isLastPage, movies); + } + } + + if (type === "append") { + if (isError) { + window.alert(errorMessage); + } else { + appendPopularMovies(isLastPage, movies); + } + } + } +}; diff --git a/src/pages/search.ts b/src/pages/search.ts new file mode 100644 index 0000000000..e88f85e0f0 --- /dev/null +++ b/src/pages/search.ts @@ -0,0 +1,69 @@ +import { Movie } from "../apis/movie/type.ts"; +import { getSearchedMovies } from "../apis/search/api.ts"; +import TMDBError from "../TMDBError.ts"; +import { + appendSearchedMovies, + renderSearch, + renderSearchEmpty, + renderSearchError, + renderSearchLoading, +} from "../dom/compositions/Search.ts"; + +export const renderSearchPage = async (type: "init" | "append") => { + let isError = false; + let isLastPage = true; + let movies: Movie[] = []; + let errorMessage = ""; + + const keyword = + new URLSearchParams(window.location.search).get("keyword") || ""; + const page = Number(sessionStorage.getItem("page") || 1); + if (keyword.trim() === "") return; + + const searchInput = document.getElementById( + "search-input", + ) as HTMLInputElement; + + if (searchInput) { + searchInput.value = keyword; + } + + try { + if (type === "init") { + renderSearchLoading(keyword); + } + + const searchResult = await getSearchedMovies({ + query: keyword, + language: "ko-KR", + page, + }); + + isLastPage = searchResult.page === searchResult.total_pages; + movies = searchResult.results; + } catch (error) { + isError = true; + errorMessage = "🚨알 수 없는 에러가 발생했습니다.🚨"; + if (error instanceof TMDBError) { + errorMessage = "🚨TMDB에서 데이터를 불러오는 중 에러가 발생했습니다🚨"; + } + } finally { + if (type === "init") { + if (isError) { + renderSearchError(errorMessage); + } else if (movies.length === 0) { + renderSearchEmpty(); + } else if (type === "init") { + renderSearch(isLastPage, movies); + } + } + + if (type === "append") { + if (isError) { + window.alert(errorMessage); + } else { + appendSearchedMovies(isLastPage, movies); + } + } + } +}; diff --git a/styles/main.css b/styles/main.css index e35292c5c1..7a0da70e0a 100644 --- a/styles/main.css +++ b/styles/main.css @@ -63,7 +63,9 @@ button.primary { } .container { - width: 1280px; + max-width: 1320px; + width: 100%; + padding: 0 20px; margin: 0 auto; } @@ -100,7 +102,8 @@ button.primary { user-select: none; position: relative; z-index: 2; - width: 1280px; + max-width: 1320px; + padding: 0 20px; margin: 0 auto; } @@ -120,8 +123,8 @@ button.primary { z-index: 10; display: flex; align-items: center; - padding: 40px 0 24px 0; - width: 1280px; + padding: 40px 20px 24px 20px; + max-width: 1320px; margin: 0 auto; } @@ -138,7 +141,8 @@ button.primary { display: flex; justify-content: space-between; - width: 32.8125rem; + max-width: 32.8125rem; + width: 80%; padding: 6px 16px; border: 2px solid var(--color-gray-300); @@ -171,11 +175,6 @@ button.primary { color: var(--color-yellow); } -.rate > img { - position: relative; - top: 2px; -} - span.rate-value { margin-left: 8px; font-weight: bold; @@ -242,3 +241,28 @@ footer.footer p:not(:last-child) { .hidden { display: none !important; } + +@media (max-width: 1440px) { + .logo { + margin: 0 auto; + } + + .movie-search { + top: 90px; + } + + .top-rated-container { + position: absolute; + bottom: 64px; + max-width: 800px; + min-width: 0; + } + + .top-rated-container h3 { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; + } +} diff --git a/styles/modal.css b/styles/modal.css index 240a7a37c1..e20a0ba7a6 100644 --- a/styles/modal.css +++ b/styles/modal.css @@ -5,7 +5,18 @@ body.modal-open { overflow: hidden; } -.modal-background { +.modal { + background-color: var(--color-bluegray-90); + border-radius: 16px; + border: none; + color: white; + z-index: 2; + position: fixed; + margin: auto; + width: 1000px; +} + +.modal::backdrop { position: fixed; top: 0; left: 0; @@ -17,72 +28,185 @@ body.modal-open { justify-content: center; align-items: center; z-index: 10; - visibility: hidden; /* 모달이 기본적으로 보이지 않도록 설정 */ - opacity: 0; - transition: - opacity 0.3s ease, - visibility 0.3s ease; -} - -.modal-background.active { - visibility: visible; - opacity: 1; -} - -.modal { - background-color: var(--color-bluegray-90); - padding: 20px; - border-radius: 16px; - color: white; - z-index: 2; - position: relative; - width: 1000px; + /*transition: opacity 0.3s ease, visibility 0.3s ease;*/ } .close-modal { position: absolute; margin: 0; padding: 0; - top: 24px; + top: 40px; right: 24px; background: none; border: none; color: white; font-size: 20px; cursor: pointer; + outline: none; } .modal-container { display: flex; + gap: 24px; + padding: 40px 24px; } .modal-image img { width: 380px; + aspect-ratio: 2/3; border-radius: 16px; + background-color: var(--color-bluegray-80); } .modal-description { + display: flex; + flex-direction: column; + gap: 16px; width: 100%; padding: 8px; - margin-left: 16px; - line-height: 1.6rem; } -.modal-description .rate > img { - position: relative; - top: 5px; +.modal-description h2 { + word-break: keep-all; + font-size: 32px; + font-weight: 700; + line-height: 100%; } -.modal-description > *:not(:last-child) { - margin-bottom: 8px; +.modal-description h3 { + font-size: 24px; + font-weight: 600; + line-height: 100%; } -.modal-description h2 { - font-size: 2rem; - margin: 0 0 8px; +.modal-description .category { + font-size: 20px; + font-weight: 400; + line-height: 100%; +} + +.modal-description .modal-rate { + display: flex; + align-items: center; + font-size: 20px; + font-weight: 400; + line-height: 100%; +} + +.modal-description .rate img { + margin: -1px 2px auto 16px; +} + +.modal-description .rate span { + margin-top: 2px; + font-size: 24px; + font-weight: 600; + line-height: 100%; + letter-spacing: 0.18px; +} + +.my-rate { + padding: 24px 0; + border-block: 1px solid var(--color-bluegray-30); +} + +.my-rate h3 { + margin-bottom: 24px; +} + +.my-rate > div { + display: flex; + gap: 16px; + align-items: center; +} + +.my-rate button { + padding: 0; + background-color: transparent; +} + +.my-rate .comment { + font-size: 24px; + font-weight: 600; + line-height: 100%; + letter-spacing: 0.18px; +} + +.my-rate .score { + color: var(--color-bluegray-30); + font-size: 24px; + font-weight: 600; + line-height: 100%; + letter-spacing: 0.18px; } .detail { - max-height: 430px; + max-height: 290px; overflow-y: auto; + font-size: 24px; + line-height: 147%; + letter-spacing: 0.18px; +} + +.detail h3 { + margin-bottom: 16px; +} + +@media (max-width: 1024px) { + .modal { + width: 100vw; + margin: auto 0 0 0; + border-radius: 16px 16px 0 0; + } + + .modal-container { + flex-direction: column; + } + + .modal-image { + margin: auto; + } + + .modal-image img { + width: 200px; + } + + .modal-description > h2 { + text-align: center; + } + + .modal-description > p { + text-align: center; + } + + .modal-description .modal-rate { + margin: 0 auto; + } +} + +@media (max-width: 640px) { + .modal-image img { + display: none; + } + + .modal-description { + text-align: center; + } + + .modal-description > p { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .my-rate > div { + flex-wrap: wrap; + justify-content: center; + } + + .my-rate .rate-button-container { + width: 100%; + display: flex; + justify-content: center; + } } diff --git a/styles/reset.css b/styles/reset.css index 3675e64525..06915bb5e8 100644 --- a/styles/reset.css +++ b/styles/reset.css @@ -91,6 +91,7 @@ video { font: inherit; vertical-align: baseline; } + /* HTML5 display-role reset for older browsers */ article, aside, @@ -105,17 +106,21 @@ nav, section { display: block; } + body { line-height: 1; } + ol, ul { list-style: none; } + blockquote, q { quotes: none; } + blockquote:before, blockquote:after, q:before, @@ -123,7 +128,17 @@ q:after { content: ""; content: none; } + table { border-collapse: collapse; border-spacing: 0; } + +dialog { + padding: 0; + margin: 0; + border: none; + background: transparent; + max-width: none; + max-height: none; +} diff --git a/styles/thumbnail.css b/styles/thumbnail.css index 7e7b5a04f7..b8694d3f48 100644 --- a/styles/thumbnail.css +++ b/styles/thumbnail.css @@ -1,10 +1,11 @@ @import "./colors.css"; .thumbnail-list { - margin: 0 auto 56px; display: grid; - grid-template-columns: repeat(5, 200px); + grid-template-columns: repeat(auto-fill, 200px); + justify-content: center; gap: 70px; + width: 100%; } .thumbnail { @@ -21,6 +22,10 @@ cursor: pointer; } +.item-desc strong { + line-height: 142%; +} + .item-desc > *:not(:last-child) { position: relative; margin-bottom: 4px; @@ -39,7 +44,7 @@ p.rate > span { .item .star { width: 16px; - top: 1px; + margin-top: 4px; } .skeleton .thumbnail { diff --git a/templates/modal.html b/templates/modal.html deleted file mode 100644 index 42e6388cab..0000000000 --- a/templates/modal.html +++ /dev/null @@ -1,522 +0,0 @@ - - - - - - - - - - - 영화 리뷰 - - -
      -
      -
      - -
      -

      - MovieList -

      -
      -
      - - 9.5 -
      -
      인사이드 아웃2
      - -
      -
      -
      -
      -
      -
        -
      • 상영 중

      • -
      • 인기순

      • -
      • 평점순

      • -
      • 상영 예정

      • -
      -
      -
      -

      지금 인기 있는 영화

      -
        -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      • -
        - 인사이드 아웃 2 -
        -

        - 7.7 -

        - 인사이드 아웃 2 -
        -
        -
      • -
      -
      -
      -
      - -
      -

      © 우아한테크코스 All Rights Reserved.

      -

      -
      -
      - - - - - -