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) => `
+
+
+
+
+
+
+
+
+
+
${movie?.title ?? "제목을 불러올 수 없습니다."}
+
+ ${(movie?.genres.map((genre) => genre.name).join(", ") ?? "장르를 불러올 수 없습니다.") || "장르 정보가 없습니다."}
+
+
+ 평균
+
+
+ ${movie?.vote_average ?? "0"}
+
+
+
+
+
줄거리
+
+ ${(movie?.overview ?? "줄거리를 불러올 수 없습니다.") || "줄거리 정보가 없습니다."}
+
+
+
+
+
+`;
+
+// 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?.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.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.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 @@
-
-
-
-
-
-
-
-
-
-
- 영화 리뷰
-
-
-
-
-
-
-
-
-
-
-
-
-
-
9.5
-
-
인사이드 아웃2
-
자세히 보기
-
-
-
-
-
-
- 상영 중
- 인기순
- 평점순
- 상영 예정
-
-
-
- 지금 인기 있는 영화
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
- 7.7
-
-
인사이드 아웃 2
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
인사이드 아웃 2
-
- 2024 · 모험, 애니메이션, 코미디, 드라마, 가족
-
-
- 7.7
-
-
-
- 13살이 된 라일리의 행복을 위해 매일 바쁘게 머릿속 감정 컨트롤
- 본부를 운영하는 ‘기쁨’, ‘슬픔’, ‘버럭’, ‘까칠’, ‘소심’. 그러던
- 어느 날, 낯선 감정인 ‘불안’, ‘당황’, ‘따분’, ‘부럽’이가 본부에
- 등장하고, 언제나 최악의 상황을 대비하며 제멋대로인 ‘불안’이와 기존
- 감정들은 계속 충돌한다. 결국 새로운 감정들에 의해 본부에서
- 쫓겨나게 된 기존 감정들은 다시 본부로 돌아가기 위해 위험천만한
- 모험을 시작하는데…
-
-
-
-
-
-
-
-
-