From ab98b35135c9ee1f574132dcaa9043e6117c3a81 Mon Sep 17 00:00:00 2001 From: boyeon Date: Tue, 31 Mar 2026 14:42:25 +0900 Subject: [PATCH 01/77] =?UTF-8?q?docs:=20=EC=98=81=ED=99=94=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20=EB=AF=B8?= =?UTF-8?q?=EC=85=98=20=EC=9A=94=EA=B5=AC=EC=82=AC=ED=95=AD=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: stardusty-lab --- README.md | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 44a1497f67..e77507c54d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,31 @@ -# javascript-movie-review +# 1단계 - 영화 목록 불러오기 -FE 레벨1 영화 리뷰 미션 +FE 레벨1 영화 리뷰 미션입니다. + +## 영상 목록 조회 기능 + +- [ ] 영상목록을 불러온다. +- [ ] 영상 목록이 렌더링 되기 전에 Skeleton UI를 보여준다. +- [ ] 불러온 영상 목록을 화면에 렌더링한다. +- [ ] 영상 목록 하단에 더보기 버튼을 보여준다. +- [ ] 더보기 버튼 클릭 시 영화 목록을 추가한다. +- [ ] 영상 목록의 끝에 도달한 경우에는 더보기 버튼을 출력하지 않는다. + +### 필터링 기능 + +- [ ] 상영중 탭버튼을 누르면 해당 조건으로 영상중인 영화목록을 불러온다 +- [ ] 인기순 탭버튼을 누르면 해당 조건으로 영상중인 영화목록을 불러온다 +- [ ] 평점순 탭버튼을 누르면 해당 조건으로 영상중인 영화목록을 불러온다 +- [ ] 상영예정 탭버튼을 누르면 해당 조건으로 영상중인 영화목록을 불러온다 + +## 영화 검색 기능 + +- [ ] 화면 상단의 검색창에서 입력을 할 수 있다. +- [ ] 검색버튼을 클릭하면 검색어를 기준으로 영화를 필터링한다. +- [ ] 검색결과를 노출시킬때 'XXX 검색 결과'를 출력한다. +- [ ] 검색결과가 없으면 없다는 안내메세지를 출력한다. + +## 상세 팝업 + +- [ ] 영화를 누르면 상세안내 팝업이 뜬다(포스터, 제목, 줄거리, 별점, 년도, 카테고리). +- [ ] 팝업의 닫기를 누르면 close 된다. From eb0458cf2db9c1f151be6a9e5c53cb79bd0d690a Mon Sep 17 00:00:00 2001 From: boyeon Date: Tue, 31 Mar 2026 14:57:20 +0900 Subject: [PATCH 02/77] =?UTF-8?q?chore:=20=EC=B4=88=EA=B8=B0=20=EB=A0=8C?= =?UTF-8?q?=EB=8D=94=EB=A7=81=20=ED=99=94=EB=A9=B4=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 70 +++++++++++++++++- {templates => public}/images/logo.png | Bin .../images/modal_button_close.png | Bin {templates => public}/images/star_empty.png | Bin {templates => public}/images/star_filled.png | Bin .../images/woowacourse_logo.png | Bin {templates => public}/styles/colors.css | 0 {templates => public}/styles/index.css | 0 {templates => public}/styles/main.css | 0 {templates => public}/styles/modal.css | 0 {templates => public}/styles/reset.css | 0 {templates => public}/styles/tab.css | 0 {templates => public}/styles/thumbnail.css | 0 src/main.ts | 2 +- 14 files changed, 69 insertions(+), 3 deletions(-) rename {templates => public}/images/logo.png (100%) rename {templates => public}/images/modal_button_close.png (100%) rename {templates => public}/images/star_empty.png (100%) rename {templates => public}/images/star_filled.png (100%) rename {templates => public}/images/woowacourse_logo.png (100%) rename {templates => public}/styles/colors.css (100%) rename {templates => public}/styles/index.css (100%) rename {templates => public}/styles/main.css (100%) rename {templates => public}/styles/modal.css (100%) rename {templates => public}/styles/reset.css (100%) rename {templates => public}/styles/tab.css (100%) rename {templates => public}/styles/thumbnail.css (100%) diff --git a/index.html b/index.html index a7fb32ffe4..794e51c7d6 100644 --- a/index.html +++ b/index.html @@ -1,12 +1,78 @@ - + + + + + 영화 리뷰 -
+
+
+
+ +
+

+ MovieList +

+
+ +
+
+
+
+
+ +
+
+

지금 인기 있는 영화

+
    +
    +
    +
    + +
    +

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

    +

    +
    +
    + + diff --git a/templates/images/logo.png b/public/images/logo.png similarity index 100% rename from templates/images/logo.png rename to public/images/logo.png diff --git a/templates/images/modal_button_close.png b/public/images/modal_button_close.png similarity index 100% rename from templates/images/modal_button_close.png rename to public/images/modal_button_close.png diff --git a/templates/images/star_empty.png b/public/images/star_empty.png similarity index 100% rename from templates/images/star_empty.png rename to public/images/star_empty.png diff --git a/templates/images/star_filled.png b/public/images/star_filled.png similarity index 100% rename from templates/images/star_filled.png rename to public/images/star_filled.png diff --git a/templates/images/woowacourse_logo.png b/public/images/woowacourse_logo.png similarity index 100% rename from templates/images/woowacourse_logo.png rename to public/images/woowacourse_logo.png diff --git a/templates/styles/colors.css b/public/styles/colors.css similarity index 100% rename from templates/styles/colors.css rename to public/styles/colors.css diff --git a/templates/styles/index.css b/public/styles/index.css similarity index 100% rename from templates/styles/index.css rename to public/styles/index.css diff --git a/templates/styles/main.css b/public/styles/main.css similarity index 100% rename from templates/styles/main.css rename to public/styles/main.css diff --git a/templates/styles/modal.css b/public/styles/modal.css similarity index 100% rename from templates/styles/modal.css rename to public/styles/modal.css diff --git a/templates/styles/reset.css b/public/styles/reset.css similarity index 100% rename from templates/styles/reset.css rename to public/styles/reset.css diff --git a/templates/styles/tab.css b/public/styles/tab.css similarity index 100% rename from templates/styles/tab.css rename to public/styles/tab.css diff --git a/templates/styles/thumbnail.css b/public/styles/thumbnail.css similarity index 100% rename from templates/styles/thumbnail.css rename to public/styles/thumbnail.css diff --git a/src/main.ts b/src/main.ts index f5ba859953..ba21be4fb0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import image from "../templates/images/star_filled.png"; +import image from "../public/images/star_filled.png"; addEventListener("load", () => { const app = document.querySelector("#app"); From 3ea0f8bb2445475f27293576e36b5031763aa6e9 Mon Sep 17 00:00:00 2001 From: boyeon Date: Tue, 31 Mar 2026 15:32:29 +0900 Subject: [PATCH 03/77] =?UTF-8?q?feat:=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EB=B2=88=ED=98=B8=EC=97=90=20=EB=94=B0=EB=9D=BC=20=EC=98=81?= =?UTF-8?q?=EC=83=81=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20=EB=B6=88=EB=9F=AC?= =?UTF-8?q?=EC=98=A4=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ README.md | 2 +- src/constants/env.ts | 2 ++ src/main.ts | 24 ++++++++++++++++-------- src/vite-env.d.ts | 8 ++++++++ 5 files changed, 29 insertions(+), 9 deletions(-) create mode 100644 src/constants/env.ts create mode 100644 src/vite-env.d.ts diff --git a/.gitignore b/.gitignore index a547bf36d8..50c8dda2af 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,5 @@ dist-ssr *.njsproj *.sln *.sw? + +.env diff --git a/README.md b/README.md index e77507c54d..4abc6db765 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ FE 레벨1 영화 리뷰 미션입니다. ## 영상 목록 조회 기능 -- [ ] 영상목록을 불러온다. +- [x] 페이지 번호에 따른 영상목록을 불러온다. - [ ] 영상 목록이 렌더링 되기 전에 Skeleton UI를 보여준다. - [ ] 불러온 영상 목록을 화면에 렌더링한다. - [ ] 영상 목록 하단에 더보기 버튼을 보여준다. diff --git a/src/constants/env.ts b/src/constants/env.ts new file mode 100644 index 0000000000..0c9898a5d0 --- /dev/null +++ b/src/constants/env.ts @@ -0,0 +1,2 @@ +export const apiUrl = import.meta.env.VITE_API_URL; +export const apiKey = import.meta.env.VITE_API_KEY; diff --git a/src/main.ts b/src/main.ts index ba21be4fb0..4c28921eca 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,11 +1,19 @@ -import image from "../public/images/star_filled.png"; +import { apiUrl, apiKey } from "./constants/env.ts"; -addEventListener("load", () => { - const app = document.querySelector("#app"); - const buttonImage = document.createElement("img"); - buttonImage.src = image; +const getMoviePopular = ({ page }: { page: number }) => { + const url = `${apiUrl}/movie/popular?page=${page}`; + return fetch(url, { + method: "get", + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }).then((res) => { + return res.json(); + }); +}; - if (app) { - app.appendChild(buttonImage); - } +addEventListener("load", () => { + getMoviePopular({ page: 2 }).then((res) => { + console.log(res); + }); }); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000000..dbbcc48adc --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,8 @@ +interface ImportMetaEnv { + readonly VITE_API_URL: string; + readonly VITE_API_KEY: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} From 00ff192753b78ea94393b332a8a22abadb6762e2 Mon Sep 17 00:00:00 2001 From: boyeon Date: Tue, 31 Mar 2026 16:32:08 +0900 Subject: [PATCH 04/77] =?UTF-8?q?feat:=20=EB=B6=88=EB=9F=AC=EC=98=A8=20?= =?UTF-8?q?=EC=98=81=ED=99=94=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=97=90=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- index.html | 19 ++++++++++++++++++- src/main.ts | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4abc6db765..1097001e80 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ FE 레벨1 영화 리뷰 미션입니다. - [x] 페이지 번호에 따른 영상목록을 불러온다. - [ ] 영상 목록이 렌더링 되기 전에 Skeleton UI를 보여준다. -- [ ] 불러온 영상 목록을 화면에 렌더링한다. +- [x] 불러온 영화 목록을 화면에 렌더링한다. - [ ] 영상 목록 하단에 더보기 버튼을 보여준다. - [ ] 더보기 버튼 클릭 시 영화 목록을 추가한다. - [ ] 영상 목록의 끝에 도달한 경우에는 더보기 버튼을 출력하지 않는다. diff --git a/index.html b/index.html index 794e51c7d6..e5f0978474 100644 --- a/index.html +++ b/index.html @@ -57,7 +57,24 @@

    지금 인기 있는 영화

    -
      +
        + +
      diff --git a/src/main.ts b/src/main.ts index 4c28921eca..705186774d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,8 +12,58 @@ const getMoviePopular = ({ page }: { page: number }) => { }); }; +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; +} + +interface Movies { + page: number; + results: Movie[]; + total_pages: number; + total_results: number; +} + addEventListener("load", () => { - getMoviePopular({ page: 2 }).then((res) => { - console.log(res); + getMoviePopular({ page: 1 }).then((movies: Movies) => { + const thumbnailList = document.querySelector(".thumbnail-list"); + const itemList = document.querySelector(`#movie-item`); + + if (!itemList) return; + + movies.results.forEach((movie: Movie) => { + const item = itemList.content.cloneNode(true) as DocumentFragment; + + const thumbnail = item.querySelector(".thumbnail"); + if (!thumbnail) return; + thumbnail.src = + `https://media.themoviedb.org/t/p/w220_and_h330_face` + + movie.poster_path; + thumbnail.alt = movie.title; + + const itemDesc = item.querySelector(".item-desc"); + + const rate = itemDesc?.querySelector("span"); + if (!rate) return; + rate.textContent = movie.vote_average.toString(); + + const title = itemDesc?.querySelector("strong"); + if (!title) return; + title.textContent = movie.title; + + thumbnailList?.appendChild(item); + }); }); }); From 2bff17a72bf8eb299dd4b4419aa88cbcf8d2ed71 Mon Sep 17 00:00:00 2001 From: boyeon Date: Tue, 31 Mar 2026 16:41:43 +0900 Subject: [PATCH 05/77] =?UTF-8?q?reafctor:=20api=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.ts | 14 +------------- src/service/api.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 13 deletions(-) create mode 100644 src/service/api.ts diff --git a/src/main.ts b/src/main.ts index 705186774d..e8b13f9ad5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,16 +1,4 @@ -import { apiUrl, apiKey } from "./constants/env.ts"; - -const getMoviePopular = ({ page }: { page: number }) => { - const url = `${apiUrl}/movie/popular?page=${page}`; - return fetch(url, { - method: "get", - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }).then((res) => { - return res.json(); - }); -}; +import { getMoviePopular } from "./service/api"; interface Movie { adult: boolean; diff --git a/src/service/api.ts b/src/service/api.ts new file mode 100644 index 0000000000..f6db6bcf9a --- /dev/null +++ b/src/service/api.ts @@ -0,0 +1,13 @@ +import { apiUrl, apiKey } from "../constants/env"; + +export const getMoviePopular = ({ page }: { page: number }) => { + const url = `${apiUrl}/movie/popular?page=${page}`; + return fetch(url, { + method: "get", + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }).then((res) => { + return res.json(); + }); +}; From 07fd96273904edf4ee020e85ff3fcfa75f174f0c Mon Sep 17 00:00:00 2001 From: boyeon Date: Tue, 31 Mar 2026 17:55:55 +0900 Subject: [PATCH 06/77] =?UTF-8?q?refactor:=20=EC=98=81=ED=99=94=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=ED=95=A8=EC=88=98=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 4 +- src/main.ts | 102 +++++++------ src/service/api.ts | 13 +- src/service/dto.ts | 23 +++ test/api.test.ts | 23 +++ test/fixtures.ts | 347 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 453 insertions(+), 59 deletions(-) create mode 100644 src/service/dto.ts create mode 100644 test/api.test.ts create mode 100644 test/fixtures.ts diff --git a/index.html b/index.html index e5f0978474..5c00606545 100644 --- a/index.html +++ b/index.html @@ -58,8 +58,8 @@

      지금 인기 있는 영화

        -
      - +
      diff --git a/src/main.ts b/src/main.ts index 92251c79b3..aceda6ddae 100644 --- a/src/main.ts +++ b/src/main.ts @@ -68,6 +68,10 @@ const renderTopRatedMovie = (movies: Movies) => { title.textContent = topRatedMovie.title; }; +const condition = { + page: 1, +}; + addEventListener("load", async () => { (async () => { const topRatedMovies = await getTopRatedMovie(); @@ -75,7 +79,16 @@ addEventListener("load", async () => { })(); (async () => { - const movies = await getMoviePopular({ page: 1 }); + const movies = await getMoviePopular({ page: condition.page }); renderMovies(movies); })(); + + const moreButton = document.querySelector("#more-button"); + moreButton?.addEventListener("click", () => { + condition.page += 1; + (async () => { + const movies = await getMoviePopular({ page: condition.page }); + renderMovies(movies); + })(); + }); }); From d0d406c653b7c873ba5a0483dca52fb65cb87bdc Mon Sep 17 00:00:00 2001 From: boyeon Date: Tue, 31 Mar 2026 19:42:28 +0900 Subject: [PATCH 09/77] =?UTF-8?q?feat:=20=EC=98=81=ED=99=94=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EB=81=9D=EC=97=90=20=EB=8F=84=EB=8B=AC=ED=95=9C=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=EC=97=90=20=EB=8D=94=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=88=A8=EA=B9=80=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: stardusty-lab stardusty-lab@users.noreply.github.com --- README.md | 8 ++++---- src/main.ts | 20 ++++++++++++++------ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fdb83c950c..f2799e2b02 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,15 @@ FE 레벨1 영화 리뷰 미션입니다. -## 영상 목록 조회 기능 +## 영화 목록 조회 기능 - [x] 페이지 상단에 평점이 가장 높은 영화를 출력한다. - [x] 페이지 번호에 따른 영상목록을 불러온다. -- [ ] 영상 목록이 렌더링 되기 전에 Skeleton UI를 보여준다. +- [ ] 영화 목록이 렌더링 되기 전에 Skeleton UI를 보여준다. - [x] 불러온 영화 목록을 화면에 렌더링한다. -- [x] 영상 목록 하단에 더보기 버튼을 보여준다. +- [x] 영화 목록 하단에 더보기 버튼을 보여준다. - [x] 더보기 버튼 클릭 시 영화 목록을 추가한다. -- [ ] 영상 목록의 끝에 도달한 경우에는 더보기 버튼을 출력하지 않는다. +- [x] 영화 목록의 끝에 도달한 경우에는 더보기 버튼을 출력하지 않는다. ### 필터링 기능 diff --git a/src/main.ts b/src/main.ts index aceda6ddae..658bd77032 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,7 +11,7 @@ const createMovieNode = (movie: Movie): DocumentFragment | null => { true, ) as DocumentFragment; - const movieItem = movieFragment.querySelector("li"); + const movieItem = movieFragment.querySelector("li"); if (!movieItem) return null; movieItem.dataset.movieId = String(movie.id); @@ -24,17 +24,23 @@ const createMovieNode = (movie: Movie): DocumentFragment | null => { const itemDesc = movieFragment.querySelector(".item-desc"); - const rate = itemDesc?.querySelector("span"); + const rate = itemDesc?.querySelector("span"); if (!rate) return null; rate.textContent = movie.vote_average.toString(); - const title = itemDesc?.querySelector("strong"); + const title = itemDesc?.querySelector("strong"); if (!title) return null; title.textContent = movie.title; return movieFragment; }; +const hideMoreButton = () => { + const moreButton = document.querySelector("#more-button"); + if (!moreButton) return null; + moreButton.style.display = "none"; +}; + const renderMovies = (movies: Movies): void => { const thumbnailList = document.querySelector(".thumbnail-list"); @@ -44,13 +50,15 @@ const renderMovies = (movies: Movies): void => { thumbnailList?.appendChild(movieNode); } }); + + if (movies.page === movies.total_pages) { + hideMoreButton(); + } }; const renderTopRatedMovie = (movies: Movies) => { const topRatedMovie = movies.results[0]; - const topRatedContainer = document.querySelector( - ".top-rated-container", - ); + const topRatedContainer = document.querySelector(".top-rated-container"); if (!topRatedContainer) return null; const backgroundContainer = document.querySelector( From 2911dbc877dc14a8cb435c76517d61a240b3daba Mon Sep 17 00:00:00 2001 From: boyeon Date: Tue, 31 Mar 2026 20:30:17 +0900 Subject: [PATCH 10/77] =?UTF-8?q?feat:=20=EC=98=81=ED=99=94=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: stardusty-lab stardusty-lab@users.noreply.github.com --- README.md | 6 ++--- index.html | 41 +++++++++++++++-------------- public/styles/main.css | 2 ++ src/main.ts | 58 +++++++++++++++++++++++++++++++++++++++++- src/service/api.ts | 18 +++++++++++++ templates/index.html | 3 ++- 6 files changed, 104 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index f2799e2b02..b196de1c40 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,9 @@ FE 레벨1 영화 리뷰 미션입니다. ## 영화 검색 기능 -- [ ] 화면 상단의 검색창에서 입력을 할 수 있다. -- [ ] 검색버튼을 클릭하면 검색어를 기준으로 영화를 필터링한다. -- [ ] 검색결과를 노출시킬때 'XXX 검색 결과'를 출력한다. +- [x] 화면 상단의 검색창에서 입력을 할 수 있다. +- [x] 검색버튼을 클릭하면 검색어를 기준으로 영화를 필터링한다. +- [x] 검색결과를 노출시킬때 'XXX 검색 결과'를 출력한다. - [ ] 검색결과가 없으면 없다는 안내메세지를 출력한다. ## 상세 팝업 diff --git a/index.html b/index.html index bad3919e58..96231bc6da 100644 --- a/index.html +++ b/index.html @@ -18,6 +18,10 @@

      MovieList

      + + + +
      @@ -29,7 +33,7 @@

      -
      +
      -

      지금 인기 있는 영화

      -
        - +
        diff --git a/public/styles/main.css b/public/styles/main.css index bb2e9a04ac..ac77954246 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -46,6 +46,8 @@ button.primary { #wrap { min-width: 1440px; background-color: var(--color-bluegray-100); + margin: 0 auto; + padding: 0 80px; } #wrap h2 { diff --git a/src/main.ts b/src/main.ts index 658bd77032..a38dc251de 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,8 @@ -import { getMoviePopular, getTopRatedMovie } from "./service/api"; +import { + getMoviePopular, + getTopRatedMovie, + getSearchMovie, +} from "./service/api"; import { Movie, Movies } from "./service/dto"; const createMovieNode = (movie: Movie): DocumentFragment | null => { @@ -41,7 +45,22 @@ const hideMoreButton = () => { moreButton.style.display = "none"; }; +const showMoreButton = () => { + const moreButton = document.querySelector("#more-button"); + if (!moreButton) return null; + moreButton.style.display = "block"; +}; + +const removeThumbnailList = () => { + const humbnailList = + document.querySelector(".thumbnail-list"); + if (!humbnailList) return; + + humbnailList.innerHTML = ""; +}; + const renderMovies = (movies: Movies): void => { + console.log(movies); const thumbnailList = document.querySelector(".thumbnail-list"); movies.results.forEach((movie: Movie) => { @@ -53,6 +72,8 @@ const renderMovies = (movies: Movies): void => { if (movies.page === movies.total_pages) { hideMoreButton(); + } else { + showMoreButton(); } }; @@ -99,4 +120,39 @@ addEventListener("load", async () => { renderMovies(movies); })(); }); + + const searchMovies = () => { + const searchInput = + document.querySelector("#search-input"); + if (!searchInput) return; + + const search = searchInput.value || ""; + + (async () => { + const movies = await getSearchMovie({ + page: condition.page, + query: search, + }); + + const movieListTitle = document.querySelector("#movie-list-title"); + if (!movieListTitle) return null; + movieListTitle.textContent = `"${search}" 검색 결과`; + + removeThumbnailList(); + renderMovies(movies); + })(); + }; + + const searchButton = document.querySelector("#search-button"); + searchButton?.addEventListener("click", () => { + searchMovies(); + }); + + const searchInput = document.querySelector("#search-input"); + if (!searchInput) return; + searchInput?.addEventListener("keyup", (e: KeyboardEvent) => { + if (e.key === "Enter") { + searchMovies(); + } + }); }); diff --git a/src/service/api.ts b/src/service/api.ts index 963f7735fb..757bd43b85 100644 --- a/src/service/api.ts +++ b/src/service/api.ts @@ -28,3 +28,21 @@ export const getTopRatedMovie = async () => { return await res.json(); }; + +export const getSearchMovie = async ({ + page, + query, +}: { + page: number; + query: string; +}): Promise => { + const url = `${apiUrl}/search/movie?page=${page}&query=${query}`; + const res = await fetch(url, { + method: "get", + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + return await res.json(); +}; diff --git a/templates/index.html b/templates/index.html index 4ebe1111eb..e093dbffd2 100644 --- a/templates/index.html +++ b/templates/index.html @@ -18,6 +18,7 @@

        MovieList

        +
        @@ -53,7 +54,7 @@

        -
        +

        지금 인기 있는 영화

        • From 181716bb9eb5a35989e1876a5a779e3faa6d49be Mon Sep 17 00:00:00 2001 From: boyeon Date: Tue, 31 Mar 2026 20:36:03 +0900 Subject: [PATCH 11/77] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EA=B0=80=20=EC=97=86=EC=9D=84=20=EB=95=8C=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=B6=9C=EB=A0=A5=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: stardusty-lab stardusty-lab@users.noreply.github.com --- README.md | 2 +- src/main.ts | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b196de1c40..a729e8a170 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ FE 레벨1 영화 리뷰 미션입니다. - [x] 화면 상단의 검색창에서 입력을 할 수 있다. - [x] 검색버튼을 클릭하면 검색어를 기준으로 영화를 필터링한다. - [x] 검색결과를 노출시킬때 'XXX 검색 결과'를 출력한다. -- [ ] 검색결과가 없으면 없다는 안내메세지를 출력한다. +- [x] 검색결과가 없으면 없다는 안내메세지를 출력한다. ## 상세 팝업 diff --git a/src/main.ts b/src/main.ts index a38dc251de..7765d117cc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -77,6 +77,15 @@ const renderMovies = (movies: Movies): void => { } }; +const renderEmpty = () => { + const thumbnailList = document.querySelector(".thumbnail-list"); + if (!thumbnailList) return; + const empty = "

          검색 결과가 없습니다

          "; + thumbnailList.innerHTML = empty; + + hideMoreButton(); +}; + const renderTopRatedMovie = (movies: Movies) => { const topRatedMovie = movies.results[0]; const topRatedContainer = document.querySelector(".top-rated-container"); @@ -139,7 +148,11 @@ addEventListener("load", async () => { movieListTitle.textContent = `"${search}" 검색 결과`; removeThumbnailList(); - renderMovies(movies); + if (movies.results.length) { + renderMovies(movies); + } else { + renderEmpty(); + } })(); }; From 1a4964c10f57b3b995ec81f3a42e33ec6010d350 Mon Sep 17 00:00:00 2001 From: boyeon Date: Wed, 1 Apr 2026 16:09:37 +0900 Subject: [PATCH 12/77] =?UTF-8?q?feat:=20=EC=98=81=ED=99=94=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EC=9D=B4=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EB=90=98?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=84=EC=97=90=20=EC=8A=A4=EC=BC=88=EB=A0=88?= =?UTF-8?q?=ED=84=B4=EC=9D=84=20=EB=A0=8C=EB=8D=94=EB=A7=81=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: stardusty-lab --- README.md | 14 ++----- index.html | 36 +++++------------- public/styles/index.css | 1 + public/styles/main.css | 2 +- public/styles/skeleton.css | 30 +++++++++++++++ src/main.ts | 75 +++++++++++++++++++++++++++++++++----- 6 files changed, 109 insertions(+), 49 deletions(-) create mode 100644 public/styles/skeleton.css diff --git a/README.md b/README.md index a729e8a170..6979665c67 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,12 @@ FE 레벨1 영화 리뷰 미션입니다. - [x] 페이지 상단에 평점이 가장 높은 영화를 출력한다. - [x] 페이지 번호에 따른 영상목록을 불러온다. -- [ ] 영화 목록이 렌더링 되기 전에 Skeleton UI를 보여준다. +- [x] 영화 목록이 렌더링 되기 전에 Skeleton UI를 보여준다. - [x] 불러온 영화 목록을 화면에 렌더링한다. - [x] 영화 목록 하단에 더보기 버튼을 보여준다. - [x] 더보기 버튼 클릭 시 영화 목록을 추가한다. - [x] 영화 목록의 끝에 도달한 경우에는 더보기 버튼을 출력하지 않는다. -### 필터링 기능 - -- [ ] 상영중 탭버튼을 누르면 해당 조건으로 영상중인 영화목록을 불러온다 -- [ ] 인기순 탭버튼을 누르면 해당 조건으로 영상중인 영화목록을 불러온다 -- [ ] 평점순 탭버튼을 누르면 해당 조건으로 영상중인 영화목록을 불러온다 -- [ ] 상영예정 탭버튼을 누르면 해당 조건으로 영상중인 영화목록을 불러온다 - ## 영화 검색 기능 - [x] 화면 상단의 검색창에서 입력을 할 수 있다. @@ -26,7 +19,6 @@ FE 레벨1 영화 리뷰 미션입니다. - [x] 검색결과를 노출시킬때 'XXX 검색 결과'를 출력한다. - [x] 검색결과가 없으면 없다는 안내메세지를 출력한다. -## 상세 팝업 +## 오류 처리 -- [ ] 영화를 누르면 상세안내 팝업이 뜬다(포스터, 제목, 줄거리, 별점, 년도, 카테고리). -- [ ] 팝업의 닫기를 누르면 close 된다. +- [ ] diff --git a/index.html b/index.html index 96231bc6da..84dcb37751 100644 --- a/index.html +++ b/index.html @@ -3,10 +3,12 @@ + + 영화 리뷰 @@ -27,35 +29,13 @@

        -
        +
         
        -

        지금 인기 있는 영화

        @@ -63,19 +43,21 @@

        지금 인기 있는 영화

      • +

        + /> 

        - +  
      • -
          +
            +
              diff --git a/public/styles/index.css b/public/styles/index.css index 7488b4f962..54376881fb 100644 --- a/public/styles/index.css +++ b/public/styles/index.css @@ -4,3 +4,4 @@ @import "./reset.css"; @import "./tab.css"; @import "./thumbnail.css"; +@import "./skeleton.css"; diff --git a/public/styles/main.css b/public/styles/main.css index ac77954246..73004d6986 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -76,7 +76,7 @@ button.primary { background-color: rgba(0, 0, 0, 0.5); width: 100%; height: 100%; - z-index: 1; + z-index: -1; } .top-rated-container { diff --git a/public/styles/skeleton.css b/public/styles/skeleton.css new file mode 100644 index 0000000000..6215964c02 --- /dev/null +++ b/public/styles/skeleton.css @@ -0,0 +1,30 @@ +.container section { + min-height: 1000px; + position: relative; +} + +.container section .thumbnail-list { + min-height: 800px; +} +.container section .skeleton-list { + position: absolute; + top: 54.4px; + left: 0; +} + +.container section .skeleton-list li .thumbnail { + display: none; +} + +.container section .skeleton-list li .dummy-img { + width: 200px; + height: 300px; + background: rgba(0, 0, 0, 0.5); + border-radius: 8px; + margin-bottom: 4px; +} + +.container section .skeleton-list.animation { + transition: opacity 3s; + opacity: 0; +} diff --git a/src/main.ts b/src/main.ts index 7765d117cc..6d49d18656 100644 --- a/src/main.ts +++ b/src/main.ts @@ -52,21 +52,21 @@ const showMoreButton = () => { }; const removeThumbnailList = () => { - const humbnailList = + const thumbnailList = document.querySelector(".thumbnail-list"); - if (!humbnailList) return; + if (!thumbnailList) return; - humbnailList.innerHTML = ""; + thumbnailList.innerHTML = ""; }; const renderMovies = (movies: Movies): void => { console.log(movies); - const thumbnailList = document.querySelector(".thumbnail-list"); + const movieList = document.querySelector("#movie-list"); movies.results.forEach((movie: Movie) => { const movieNode = createMovieNode(movie); if (movieNode) { - thumbnailList?.appendChild(movieNode); + movieList?.appendChild(movieNode); } }); @@ -91,11 +91,9 @@ const renderTopRatedMovie = (movies: Movies) => { const topRatedContainer = document.querySelector(".top-rated-container"); if (!topRatedContainer) return null; - const backgroundContainer = document.querySelector( - ".background-container", - ); - if (!backgroundContainer) return null; - backgroundContainer.style.background = `url(${`https://media.themoviedb.org/t/p/w1920_and_h800_multi_faces` + topRatedMovie.backdrop_path}) center center no-repeat`; + const overlay = document.querySelector(".overlay"); + if (!overlay) return null; + overlay.style.background = `url(${`https://media.themoviedb.org/t/p/w1920_and_h800_multi_faces` + topRatedMovie.backdrop_path}) center center no-repeat`; const rateValue = topRatedContainer.querySelector(".rate-value"); if (!rateValue) return null; @@ -106,10 +104,47 @@ const renderTopRatedMovie = (movies: Movies) => { title.textContent = topRatedMovie.title; }; +const renderSkeleton = () => { + const skeleton = document.querySelector("#skeleton"); + console.log(skeleton); + if (!skeleton) return; + + const skeletonTemplate = + document.querySelector("#movie-template"); + if (!skeletonTemplate) return null; + + for (let i = 0; i < 20; i++) { + const skeletonCloneNode = skeletonTemplate.content.cloneNode( + true, + ) as DocumentFragment; + if (!skeletonCloneNode) return null; + + skeleton.appendChild(skeletonCloneNode); + } +}; + +const removeSkeleton = () => { + const skeleton = document.querySelector("#skeleton"); + if (!skeleton) return; + + skeleton.classList.add("animation"); + + setTimeout(() => { + skeleton.classList.remove("animation"); + skeleton.replaceChildren(); + }, 3000); +}; + const condition = { page: 1, }; +const wait = (time: number) => { + return new Promise((resolve) => { + setTimeout(resolve, time); + }); +}; + addEventListener("load", async () => { (async () => { const topRatedMovies = await getTopRatedMovie(); @@ -117,8 +152,11 @@ addEventListener("load", async () => { })(); (async () => { + renderSkeleton(); + const movies = await getMoviePopular({ page: condition.page }); renderMovies(movies); + removeSkeleton(); })(); const moreButton = document.querySelector("#more-button"); @@ -143,6 +181,23 @@ addEventListener("load", async () => { query: search, }); + const topRatedMovie = + document.querySelector(".top-rated-movie"); + if (!topRatedMovie) return null; + topRatedMovie.style.display = "none"; + + const background = document.querySelector( + ".background-container", + ); + if (!background) return null; + background.style.backgroundColor = "transparent"; + background.style.height = "auto"; + + const overlay = document.querySelector(".overlay"); + if (!overlay) return null; + overlay.style.background = ""; + overlay.style.display = "none"; + const movieListTitle = document.querySelector("#movie-list-title"); if (!movieListTitle) return null; movieListTitle.textContent = `"${search}" 검색 결과`; From 6a16aa5e777e1f2c3c752ac09a14fcd423e0ceb2 Mon Sep 17 00:00:00 2001 From: boyeon Date: Wed, 1 Apr 2026 16:54:11 +0900 Subject: [PATCH 13/77] =?UTF-8?q?test:=20=EC=98=81=ED=99=94=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 20개 렌더링 확인 - 더보기 클릭시 영화 목록 추가 - 마지막 페이지에서 더보기 버튼 사라짐 Co-authored-by: stardusty-lab stardusty-lab@users.noreply.github.com --- cypress/e2e/movie-list-rendering.cy.ts | 46 ++++++++++++++++++++++++++ cypress/e2e/spec.cy.ts | 5 --- 2 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 cypress/e2e/movie-list-rendering.cy.ts delete mode 100644 cypress/e2e/spec.cy.ts diff --git a/cypress/e2e/movie-list-rendering.cy.ts b/cypress/e2e/movie-list-rendering.cy.ts new file mode 100644 index 0000000000..11cbd22e60 --- /dev/null +++ b/cypress/e2e/movie-list-rendering.cy.ts @@ -0,0 +1,46 @@ +import { moviesFixture } from "../../test/fixtures"; + +describe("template spec", () => { + beforeEach(() => { + cy.intercept("GET", "**/movie/popular?page=1", { + statusCode: 200, + body: { + page: 1, + results: [...moviesFixture.results], + total_pages: 2, + total_results: 40, + }, + }).as("getPopularPage1"); + + cy.intercept("GET", "**/movie/popular?page=2", { + statusCode: 200, + body: { + page: 2, + results: [...moviesFixture.results], + total_pages: 2, + total_results: 40, + }, + }).as("getPopularPage2"); + + cy.visit("localhost:5173"); + cy.wait("@getPopularPage1"); + }); + + it("프로그램을 시작하면 20개의 영화 목록이 렌더링 된다.", () => { + cy.get("#movie-list li").should("have.length", 20); + }); + + it("더보기 버튼을 누르면 영화 목록이 추가로 생성되어 렌더링 된다.", () => { + cy.get("#more-button").click(); + cy.wait("@getPopularPage2"); + + cy.get("#movie-list li").should("have.length.greaterThan", 20); + }); + + it("마지막 페이지까지 렌더링 됬을때 더보기 버튼을 출력하지 않는다.", () => { + cy.get("#more-button").click(); + cy.wait("@getPopularPage2"); + + cy.get("#more-button").should("not.be.visible"); + }); +}); diff --git a/cypress/e2e/spec.cy.ts b/cypress/e2e/spec.cy.ts deleted file mode 100644 index 1041fe84d1..0000000000 --- a/cypress/e2e/spec.cy.ts +++ /dev/null @@ -1,5 +0,0 @@ -describe("template spec", () => { - it("passes", () => { - cy.visit("localhost:5173"); - }); -}); From 895e47afaf518c70dea21a6c6ee8c3b219a4eda4 Mon Sep 17 00:00:00 2001 From: boyeon Date: Wed, 1 Apr 2026 17:52:35 +0900 Subject: [PATCH 14/77] =?UTF-8?q?test:=20=EC=98=81=ED=99=94=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EA=B8=B0=EB=8A=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 검색어 입력 후 검색 버튼 클릭하면 영화 목록 출력 - 검색어 입력 후 엔터 치면 영화 목록 출력 - 더보기 버튼 클릭 시 필터링 된 영화 목록 추가 - 더 이상 필터링 된 결과가 마지막 페이지면 더보기 버튼 출력 X - 검색 결과 없을 시 안내메시지 출력 Co-authored-by: stardusty-lab stardusty-lab@users.noreply.github.com --- cypress/e2e/movie-list-rendering.cy.ts | 4 +- cypress/e2e/movie-search.cy.ts | 91 +++ src/main.ts | 19 +- test/fixtures.ts | 1032 ++++++++++++++++-------- 4 files changed, 784 insertions(+), 362 deletions(-) create mode 100644 cypress/e2e/movie-search.cy.ts diff --git a/cypress/e2e/movie-list-rendering.cy.ts b/cypress/e2e/movie-list-rendering.cy.ts index 11cbd22e60..45ed6c1bda 100644 --- a/cypress/e2e/movie-list-rendering.cy.ts +++ b/cypress/e2e/movie-list-rendering.cy.ts @@ -6,7 +6,7 @@ describe("template spec", () => { statusCode: 200, body: { page: 1, - results: [...moviesFixture.results], + results: [...moviesFixture], total_pages: 2, total_results: 40, }, @@ -16,7 +16,7 @@ describe("template spec", () => { statusCode: 200, body: { page: 2, - results: [...moviesFixture.results], + results: [...moviesFixture], total_pages: 2, total_results: 40, }, diff --git a/cypress/e2e/movie-search.cy.ts b/cypress/e2e/movie-search.cy.ts new file mode 100644 index 0000000000..2f259d3951 --- /dev/null +++ b/cypress/e2e/movie-search.cy.ts @@ -0,0 +1,91 @@ +import { searchFixture } from "../../test/fixtures"; + +describe("template spec", () => { + beforeEach(() => { + cy.intercept( + "GET", + "**/search/movie?page=1&query=%EC%8A%A4%ED%8C%8C%EC%9D%B4", + { + statusCode: 200, + body: { + page: 1, + results: [...searchFixture], + total_pages: 2, + total_results: 40, + }, + }, + ).as("getSearchPage1"); + + cy.intercept( + "GET", + "**/search/movie?page=2&query=%EC%8A%A4%ED%8C%8C%EC%9D%B4", + { + statusCode: 200, + body: { + page: 2, + results: [...searchFixture], + total_pages: 2, + total_results: 40, + }, + }, + ).as("getSearchPage2"); + + cy.intercept("GET", "**/search/movie?page=1&query=%EB%B7%80", { + statusCode: 200, + body: { + page: 1, + results: [], + total_pages: 1, + total_results: 0, + }, + }).as("getSearchNoResult"); + + cy.visit("localhost:5173"); + }); + + it("검색어를 입력하고 검색 버튼을 클릭하면 필터링 된 영화 목록이 출력된다.", () => { + cy.get("#search-input").type("스파이"); + cy.get("#search-button").click(); + cy.wait("@getSearchPage1"); + + cy.get("#movie-list li").should("have.length.greaterThan", 0); + }); + + it("검색어를 입력하고 엔터를 치면 필터링 된 영화 목록이 출력된다.", () => { + cy.get("#search-input").type("스파이"); + cy.get("#search-button").type("{enter}"); + cy.wait("@getSearchPage1"); + + cy.get("#movie-list li").should("have.length.greaterThan", 0); + }); + + it("검색 후 더보기 버튼을 클릭하면 필터링 된 영화 목록이 추가로 출력된다.", () => { + cy.get("#search-input").type("스파이"); + cy.get("#search-button").click(); + cy.wait("@getSearchPage1"); + + cy.get("#more-button").click(); + cy.wait("@getSearchPage2"); + + cy.get("#movie-list li").should("have.length.greaterThan", 20); + }); + + it("필터링 된 영화 목록이 마지막 페이지면 더보기 버튼을 출력하지 않는다.", () => { + cy.get("#search-input").type("스파이"); + cy.get("#search-button").click(); + cy.wait("@getSearchPage1"); + + cy.get("#more-button").click(); + cy.wait("@getSearchPage2"); + + cy.get("#more-button").should("not.be.visible"); + }); + + it("검색 결과가 없을 때는 안내메시지를 출력한다.", () => { + cy.get("#search-input").type("뷀"); + cy.get("#search-button").click(); + cy.wait("@getSearchNoResult"); + + cy.get("#no-result").should("be.visible"); + }); +}); diff --git a/src/main.ts b/src/main.ts index 6d49d18656..61de3bb8ef 100644 --- a/src/main.ts +++ b/src/main.ts @@ -51,12 +51,11 @@ const showMoreButton = () => { moreButton.style.display = "block"; }; -const removeThumbnailList = () => { - const thumbnailList = - document.querySelector(".thumbnail-list"); - if (!thumbnailList) return; +const removeMovieList = () => { + const movieList = document.querySelector("#movie-list"); + if (!movieList) return; - thumbnailList.innerHTML = ""; + movieList.innerHTML = ""; }; const renderMovies = (movies: Movies): void => { @@ -80,7 +79,7 @@ const renderMovies = (movies: Movies): void => { const renderEmpty = () => { const thumbnailList = document.querySelector(".thumbnail-list"); if (!thumbnailList) return; - const empty = "

              검색 결과가 없습니다

              "; + const empty = '

              검색 결과가 없습니다.

              '; thumbnailList.innerHTML = empty; hideMoreButton(); @@ -139,12 +138,6 @@ const condition = { page: 1, }; -const wait = (time: number) => { - return new Promise((resolve) => { - setTimeout(resolve, time); - }); -}; - addEventListener("load", async () => { (async () => { const topRatedMovies = await getTopRatedMovie(); @@ -202,7 +195,7 @@ addEventListener("load", async () => { if (!movieListTitle) return null; movieListTitle.textContent = `"${search}" 검색 결과`; - removeThumbnailList(); + removeMovieList(); if (movies.results.length) { renderMovies(movies); } else { diff --git a/test/fixtures.ts b/test/fixtures.ts index 8087ee9959..8a4fef67ec 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -1,347 +1,685 @@ -export const moviesFixture = { - page: 1, - results: [ - { - adult: false, - backdrop_path: "/gMJngTNfaqCSCqGD4y8lVMZXKDn.jpg", - genre_ids: [28, 12, 878], - id: 640146, - original_language: "en", - original_title: "Ant-Man and the Wasp: Quantumania", - overview: - "Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange new creatures and embarking on an adventure that will push them beyond the limits of what they thought possible.", - popularity: 8567.865, - poster_path: "/ngl2FKBlU4fhbdsrtdom9LVLBXw.jpg", - release_date: "2023-02-15", - title: "Ant-Man and the Wasp: Quantumania", - video: false, - vote_average: 6.5, - vote_count: 1886, - }, - { - adult: false, - backdrop_path: "/iJQIbOPm81fPEGKt5BPuZmfnA54.jpg", - genre_ids: [16, 12, 10751, 14, 35], - id: 502356, - original_language: "en", - original_title: "The Super Mario Bros. Movie", - overview: - "While working underground to fix a water main, Brooklyn plumbers—and brothers—Mario and Luigi are transported down a mysterious pipe and wander into a magical new world. But when the brothers are separated, Mario embarks on an epic quest to find Luigi.", - popularity: 6572.614, - poster_path: "/qNBAXBIQlnOThrVvA6mA2B5ggV6.jpg", - release_date: "2023-04-05", - title: "The Super Mario Bros. Movie", - video: false, - vote_average: 7.5, - vote_count: 1456, - }, - { - adult: false, - backdrop_path: "/nDxJJyA5giRhXx96q1sWbOUjMBI.jpg", - genre_ids: [28, 35, 14], - id: 594767, - original_language: "en", - original_title: "Shazam! Fury of the Gods", - overview: - 'Billy Batson and his foster siblings, who transform into superheroes by saying "Shazam!", are forced to get back into action and fight the Daughters of Atlas, who they must stop from using a weapon that could destroy the world.', - popularity: 4274.232, - poster_path: "/2VK4d3mqqTc7LVZLnLPeRiPaJ71.jpg", - release_date: "2023-03-15", - title: "Shazam! Fury of the Gods", - video: false, - vote_average: 6.9, - vote_count: 1231, - }, - { - adult: false, - backdrop_path: "/ovM06PdF3M8wvKb06i4sjW3xoww.jpg", - genre_ids: [878, 12, 28], - id: 76600, - original_language: "en", - original_title: "Avatar: The Way of Water", - overview: - "Set more than a decade after the events of the first film, learn the story of the Sully family (Jake, Neytiri, and their kids), the trouble that follows them, the lengths they go to keep each other safe, the battles they fight to stay alive, and the tragedies they endure.", - popularity: 3365.913, - poster_path: "/t6HIqrRAclMCA60NsSmeqe9RmNV.jpg", - release_date: "2022-12-14", - title: "Avatar: The Way of Water", - video: false, - vote_average: 7.7, - vote_count: 7535, - }, - { - adult: false, - backdrop_path: "/xwA90BwZXTh8ke3CVsQlj8EOkFr.jpg", - genre_ids: [28, 12, 36, 18, 10752], - id: 948713, - original_language: "en", - original_title: "The Last Kingdom: Seven Kings Must Die", - overview: - "In the wake of King Edward's death, Uhtred of Bebbanburg and his comrades adventure across a fractured kingdom in the hopes of uniting England at last.", - popularity: 3119.049, - poster_path: "/7yyFEsuaLGTPul5UkHc5BhXnQ0k.jpg", - release_date: "2023-04-14", - title: "The Last Kingdom: Seven Kings Must Die", - video: false, - vote_average: 7.4, - vote_count: 232, - }, - { - adult: false, - backdrop_path: "/5i6SjyDbDWqyun8klUuCxrlFbyw.jpg", - genre_ids: [18, 28], - id: 677179, - original_language: "en", - original_title: "Creed III", - overview: - "After dominating the boxing world, Adonis Creed has been thriving in both his career and family life. When a childhood friend and former boxing prodigy, Damian Anderson, resurfaces after serving a long sentence in prison, he is eager to prove that he deserves his shot in the ring. The face-off between former friends is more than just a fight. To settle the score, Adonis must put his future on the line to battle Damian — a fighter who has nothing to lose.", - popularity: 2856.222, - poster_path: "/cvsXj3I9Q2iyyIo95AecSd1tad7.jpg", - release_date: "2023-03-01", - title: "Creed III", - video: false, - vote_average: 7.3, - vote_count: 1192, - }, - { - adult: false, - backdrop_path: "/bT3IpP7OopgiVuy6HCPOWLuaFAd.jpg", - genre_ids: [35, 9648, 28], - id: 638974, - original_language: "en", - original_title: "Murder Mystery 2", - overview: - "After starting their own detective agency, Nick and Audrey Spitz land a career-making case when their billionaire pal is kidnapped from his wedding.", - popularity: 1879.655, - poster_path: "/s1VzVhXlqsevi8zeCMG9A16nEUf.jpg", - release_date: "2023-03-28", - title: "Murder Mystery 2", - video: false, - vote_average: 6.5, - vote_count: 864, - }, - { - adult: false, - backdrop_path: "/7bWxAsNPv9CXHOhZbJVlj2KxgfP.jpg", - genre_ids: [27, 53], - id: 713704, - original_language: "en", - original_title: "Evil Dead Rise", - overview: - "Two sisters find an ancient vinyl that gives birth to bloodthirsty demons that run amok in a Los Angeles apartment building and thrusts them into a primal battle for survival as they face the most nightmarish version of family imaginable.", - popularity: 1696.367, - poster_path: "/mIBCtPvKZQlxubxKMeViO2UrP3q.jpg", - release_date: "2023-04-12", - title: "Evil Dead Rise", - video: false, - vote_average: 7, - vote_count: 207, - }, - { - adult: false, - backdrop_path: "/ouB7hwclG7QI3INoYJHaZL4vOaa.jpg", - genre_ids: [16, 10751, 14, 12, 35, 18], - id: 315162, - original_language: "en", - original_title: "Puss in Boots: The Last Wish", - overview: - "Puss in Boots discovers that his passion for adventure has taken its toll: He has burned through eight of his nine lives, leaving him with only one life left. Puss sets out on an epic journey to find the mythical Last Wish and restore his nine lives.", - popularity: 1347.259, - poster_path: "/kuf6dutpsT0vSVehic3EZIqkOBt.jpg", - release_date: "2022-12-07", - title: "Puss in Boots: The Last Wish", - video: false, - vote_average: 8.3, - vote_count: 5331, - }, - { - adult: false, - backdrop_path: "/h8gHn0OzBoaefsYseUByqsmEDMY.jpg", - genre_ids: [28, 53, 80], - id: 603692, - original_language: "en", - original_title: "John Wick: Chapter 4", - overview: - "With the price on his head ever increasing, John Wick uncovers a path to defeating The High Table. But before he can earn his freedom, Wick must face off against a new enemy with powerful alliances across the globe and forces that turn old friends into foes.", - popularity: 1320.735, - poster_path: "/vZloFAK7NmvMGKE7VkF5UHaz0I.jpg", - release_date: "2023-03-22", - title: "John Wick: Chapter 4", - video: false, - vote_average: 8, - vote_count: 1211, - }, - { - adult: false, - backdrop_path: "/nDmPjKLqLwWyd4Ssti8HCdhW5cZ.jpg", - genre_ids: [28], - id: 1048300, - original_language: "en", - original_title: "Adrenaline", - overview: - "A female FBI agent holidaying in Eastern Europe with her family gets her life upside down when her daughter is kidnapped. She has to team up with a criminal on the run to save her daughter before time runs out.", - popularity: 1460.629, - poster_path: "/qVzRt8c2v4gGBYsnaflXVVeQ9Lh.jpg", - release_date: "2022-12-15", - title: "Adrenaline", - video: false, - vote_average: 4, - vote_count: 4, - }, - { - adult: false, - backdrop_path: "/a2tys4sD7xzVaogPntGsT1ypVoT.jpg", - genre_ids: [53, 35, 80], - id: 804150, - original_language: "en", - original_title: "Cocaine Bear", - overview: - "Inspired by a true story, an oddball group of cops, criminals, tourists and teens converge in a Georgia forest where a 500-pound black bear goes on a murderous rampage after unintentionally ingesting cocaine.", - popularity: 1175.491, - poster_path: "/gOnmaxHo0412UVr1QM5Nekv1xPi.jpg", - release_date: "2023-02-22", - title: "Cocaine Bear", - video: false, - vote_average: 6.4, - vote_count: 878, - }, - { - adult: false, - backdrop_path: "/54IXMMEQKlkPXHqPExWy98UBmtE.jpg", - genre_ids: [27], - id: 1008005, - original_language: "es", - original_title: "La niña de la comunión", - overview: - "Spain, late 1980s. Newcomer Sara tries to fit in with the other teens in this tight-knit small town in the province of Tarragona. If only she were more like her extroverted best friend, Rebe. They go out one night at a nightclub, on the way home, they come upon a little girl holding a doll, dressed for her first communion. And that's when the nightmare begins.", - popularity: 1154.3, - poster_path: "/sP6AO11a7jWgsmT9T8j9EGIWAaZ.jpg", - release_date: "2023-02-10", - title: "The Communion Girl", - video: false, - vote_average: 6.5, - vote_count: 58, - }, - { - adult: false, - backdrop_path: "/tFaC1Fb1sv1dALB0i9Avi76MHmn.jpg", - genre_ids: [10751, 28, 12], - id: 946310, - original_language: "nl", - original_title: "De Piraten van Hiernaast II: De Ninja's van de Overkant", - overview: - "The pirates feel right at home in Sandborough, but the atmosphere cools right down when the ninjas come to live in the street. After all, pirates and ninjas are sworn enemies! While pirate captain Hector Blunderbuss struggles to get rid of his new neighbours, son Billy and ninja daughter Yuka become friends. The pirates challenge the ninjas to the ultimate battle at the village's annual hexathlon. Who will win the match? Ninjas are faster and more agile of course, but pirates are the best cheats in all of the seven seas...", - popularity: 1159.928, - poster_path: "/uDsvma9dAwnDPVuCFi99YpWvBk0.jpg", - release_date: "2022-04-20", - title: "Pirates Down the Street II: The Ninjas from Across", - video: false, - vote_average: 6.4, - vote_count: 22, - }, - { - adult: false, - backdrop_path: "/rPSJAElGxOTko1zK6uIlYnTMFxN.jpg", - genre_ids: [80], - id: 1104040, - original_language: "en", - original_title: "Gangs of Lagos", - overview: - "A group of friends who each have to navigate their own destiny, growing up on the bustling streets and neighborhood of Isale Eko, Lagos.", - popularity: 1138.252, - poster_path: "/nGwFsB6EXUCr21wzPgtP5juZPSv.jpg", - release_date: "2023-04-07", - title: "Gangs of Lagos", - video: false, - vote_average: 5.6, - vote_count: 21, - }, - { - adult: false, - backdrop_path: "/eSVu1FvGPy86TDo4hQbpuHx55DJ.jpg", - genre_ids: [878, 12, 53, 28], - id: 700391, - original_language: "en", - original_title: "65", - overview: - "65 million years ago, the only 2 survivors of a spaceship from Somaris that crash-landed on Earth must fend off dinosaurs and reach the escape vessel in time before an imminent asteroid strike threatens to destroy the planet.", - popularity: 1077.09, - poster_path: "/rzRb63TldOKdKydCvWJM8B6EkPM.jpg", - release_date: "2023-03-02", - title: "65", - video: false, - vote_average: 6.3, - vote_count: 763, - }, - { - adult: false, - backdrop_path: "/5Y5pz0NX7SZS9036I733F7uNcwK.jpg", - genre_ids: [27, 53], - id: 758323, - original_language: "en", - original_title: "The Pope's Exorcist", - overview: - "Father Gabriele Amorth, Chief Exorcist of the Vatican, investigates a young boy's terrifying possession and ends up uncovering a centuries-old conspiracy the Vatican has desperately tried to keep hidden.", - popularity: 1073.229, - poster_path: "/9JBEPLTPSm0d1mbEcLxULjJq9Eh.jpg", - release_date: "2023-04-05", - title: "The Pope's Exorcist", - video: false, - vote_average: 6.5, - vote_count: 143, - }, - { - adult: false, - backdrop_path: "/m1fgGSLK0WvRpzM1AmZu38m0Tx8.jpg", - genre_ids: [28], - id: 842945, - original_language: "en", - original_title: "Supercell", - overview: - "Good-hearted teenager William always lived in hope of following in his late father’s footsteps and becoming a storm chaser. His father’s legacy has now been turned into a storm-chasing tourist business, managed by the greedy and reckless Zane Rogers, who is now using William as the main attraction to lead a group of unsuspecting adventurers deep into the eye of the most dangerous supercell ever seen.", - popularity: 942.178, - poster_path: "/gbGHezV6yrhua0KfAgwrknSOiIY.jpg", - release_date: "2023-03-17", - title: "Supercell", - video: false, - vote_average: 6.3, - vote_count: 125, - }, - { - adult: false, - backdrop_path: "/tYcmm8XtzRdcT6kliCbHuWwLCwB.jpg", - genre_ids: [28, 80, 53], - id: 849869, - original_language: "ko", - original_title: "길복순", - overview: - "At work, she's a renowned assassin. At home, she's a single mom to a teenage daughter. Killing? That's easy. It's parenting that's the hard part.", - popularity: 958.517, - poster_path: "/taYgn3RRpCGlTGdaGQvnSIOzXFy.jpg", - release_date: "2023-02-17", - title: "Kill Boksoon", - video: false, - vote_average: 6.8, - vote_count: 184, - }, - { - adult: false, - backdrop_path: "/eNJhWy7xFzR74SYaSJHqJZuroDm.jpg", - genre_ids: [28, 878], - id: 1033219, - original_language: "en", - original_title: "Attack on Titan", - overview: - "As viable water is depleted on Earth, a mission is sent to Saturn's moon Titan to retrieve sustainable H2O reserves from its alien inhabitants. But just as the humans acquire the precious resource, they are attacked by Titan rebels, who don't trust that the Earthlings will leave in peace.", - popularity: 897.66, - poster_path: "/qNz4l8UgTkD8rlqiKZ556pCJ9iO.jpg", - release_date: "2022-09-30", - title: "Attack on Titan", - video: false, - vote_average: 6.1, - vote_count: 105, - }, - ], - total_pages: 38029, - total_results: 760569, -}; +export const moviesFixture = [ + { + adult: false, + backdrop_path: "/gMJngTNfaqCSCqGD4y8lVMZXKDn.jpg", + genre_ids: [28, 12, 878], + id: 640146, + original_language: "en", + original_title: "Ant-Man and the Wasp: Quantumania", + overview: + "Super-Hero partners Scott Lang and Hope van Dyne, along with with Hope's parents Janet van Dyne and Hank Pym, and Scott's daughter Cassie Lang, find themselves exploring the Quantum Realm, interacting with strange new creatures and embarking on an adventure that will push them beyond the limits of what they thought possible.", + popularity: 8567.865, + poster_path: "/ngl2FKBlU4fhbdsrtdom9LVLBXw.jpg", + release_date: "2023-02-15", + title: "Ant-Man and the Wasp: Quantumania", + video: false, + vote_average: 6.5, + vote_count: 1886, + }, + { + adult: false, + backdrop_path: "/iJQIbOPm81fPEGKt5BPuZmfnA54.jpg", + genre_ids: [16, 12, 10751, 14, 35], + id: 502356, + original_language: "en", + original_title: "The Super Mario Bros. Movie", + overview: + "While working underground to fix a water main, Brooklyn plumbers—and brothers—Mario and Luigi are transported down a mysterious pipe and wander into a magical new world. But when the brothers are separated, Mario embarks on an epic quest to find Luigi.", + popularity: 6572.614, + poster_path: "/qNBAXBIQlnOThrVvA6mA2B5ggV6.jpg", + release_date: "2023-04-05", + title: "The Super Mario Bros. Movie", + video: false, + vote_average: 7.5, + vote_count: 1456, + }, + { + adult: false, + backdrop_path: "/nDxJJyA5giRhXx96q1sWbOUjMBI.jpg", + genre_ids: [28, 35, 14], + id: 594767, + original_language: "en", + original_title: "Shazam! Fury of the Gods", + overview: + 'Billy Batson and his foster siblings, who transform into superheroes by saying "Shazam!", are forced to get back into action and fight the Daughters of Atlas, who they must stop from using a weapon that could destroy the world.', + popularity: 4274.232, + poster_path: "/2VK4d3mqqTc7LVZLnLPeRiPaJ71.jpg", + release_date: "2023-03-15", + title: "Shazam! Fury of the Gods", + video: false, + vote_average: 6.9, + vote_count: 1231, + }, + { + adult: false, + backdrop_path: "/ovM06PdF3M8wvKb06i4sjW3xoww.jpg", + genre_ids: [878, 12, 28], + id: 76600, + original_language: "en", + original_title: "Avatar: The Way of Water", + overview: + "Set more than a decade after the events of the first film, learn the story of the Sully family (Jake, Neytiri, and their kids), the trouble that follows them, the lengths they go to keep each other safe, the battles they fight to stay alive, and the tragedies they endure.", + popularity: 3365.913, + poster_path: "/t6HIqrRAclMCA60NsSmeqe9RmNV.jpg", + release_date: "2022-12-14", + title: "Avatar: The Way of Water", + video: false, + vote_average: 7.7, + vote_count: 7535, + }, + { + adult: false, + backdrop_path: "/xwA90BwZXTh8ke3CVsQlj8EOkFr.jpg", + genre_ids: [28, 12, 36, 18, 10752], + id: 948713, + original_language: "en", + original_title: "The Last Kingdom: Seven Kings Must Die", + overview: + "In the wake of King Edward's death, Uhtred of Bebbanburg and his comrades adventure across a fractured kingdom in the hopes of uniting England at last.", + popularity: 3119.049, + poster_path: "/7yyFEsuaLGTPul5UkHc5BhXnQ0k.jpg", + release_date: "2023-04-14", + title: "The Last Kingdom: Seven Kings Must Die", + video: false, + vote_average: 7.4, + vote_count: 232, + }, + { + adult: false, + backdrop_path: "/5i6SjyDbDWqyun8klUuCxrlFbyw.jpg", + genre_ids: [18, 28], + id: 677179, + original_language: "en", + original_title: "Creed III", + overview: + "After dominating the boxing world, Adonis Creed has been thriving in both his career and family life. When a childhood friend and former boxing prodigy, Damian Anderson, resurfaces after serving a long sentence in prison, he is eager to prove that he deserves his shot in the ring. The face-off between former friends is more than just a fight. To settle the score, Adonis must put his future on the line to battle Damian — a fighter who has nothing to lose.", + popularity: 2856.222, + poster_path: "/cvsXj3I9Q2iyyIo95AecSd1tad7.jpg", + release_date: "2023-03-01", + title: "Creed III", + video: false, + vote_average: 7.3, + vote_count: 1192, + }, + { + adult: false, + backdrop_path: "/bT3IpP7OopgiVuy6HCPOWLuaFAd.jpg", + genre_ids: [35, 9648, 28], + id: 638974, + original_language: "en", + original_title: "Murder Mystery 2", + overview: + "After starting their own detective agency, Nick and Audrey Spitz land a career-making case when their billionaire pal is kidnapped from his wedding.", + popularity: 1879.655, + poster_path: "/s1VzVhXlqsevi8zeCMG9A16nEUf.jpg", + release_date: "2023-03-28", + title: "Murder Mystery 2", + video: false, + vote_average: 6.5, + vote_count: 864, + }, + { + adult: false, + backdrop_path: "/7bWxAsNPv9CXHOhZbJVlj2KxgfP.jpg", + genre_ids: [27, 53], + id: 713704, + original_language: "en", + original_title: "Evil Dead Rise", + overview: + "Two sisters find an ancient vinyl that gives birth to bloodthirsty demons that run amok in a Los Angeles apartment building and thrusts them into a primal battle for survival as they face the most nightmarish version of family imaginable.", + popularity: 1696.367, + poster_path: "/mIBCtPvKZQlxubxKMeViO2UrP3q.jpg", + release_date: "2023-04-12", + title: "Evil Dead Rise", + video: false, + vote_average: 7, + vote_count: 207, + }, + { + adult: false, + backdrop_path: "/ouB7hwclG7QI3INoYJHaZL4vOaa.jpg", + genre_ids: [16, 10751, 14, 12, 35, 18], + id: 315162, + original_language: "en", + original_title: "Puss in Boots: The Last Wish", + overview: + "Puss in Boots discovers that his passion for adventure has taken its toll: He has burned through eight of his nine lives, leaving him with only one life left. Puss sets out on an epic journey to find the mythical Last Wish and restore his nine lives.", + popularity: 1347.259, + poster_path: "/kuf6dutpsT0vSVehic3EZIqkOBt.jpg", + release_date: "2022-12-07", + title: "Puss in Boots: The Last Wish", + video: false, + vote_average: 8.3, + vote_count: 5331, + }, + { + adult: false, + backdrop_path: "/h8gHn0OzBoaefsYseUByqsmEDMY.jpg", + genre_ids: [28, 53, 80], + id: 603692, + original_language: "en", + original_title: "John Wick: Chapter 4", + overview: + "With the price on his head ever increasing, John Wick uncovers a path to defeating The High Table. But before he can earn his freedom, Wick must face off against a new enemy with powerful alliances across the globe and forces that turn old friends into foes.", + popularity: 1320.735, + poster_path: "/vZloFAK7NmvMGKE7VkF5UHaz0I.jpg", + release_date: "2023-03-22", + title: "John Wick: Chapter 4", + video: false, + vote_average: 8, + vote_count: 1211, + }, + { + adult: false, + backdrop_path: "/nDmPjKLqLwWyd4Ssti8HCdhW5cZ.jpg", + genre_ids: [28], + id: 1048300, + original_language: "en", + original_title: "Adrenaline", + overview: + "A female FBI agent holidaying in Eastern Europe with her family gets her life upside down when her daughter is kidnapped. She has to team up with a criminal on the run to save her daughter before time runs out.", + popularity: 1460.629, + poster_path: "/qVzRt8c2v4gGBYsnaflXVVeQ9Lh.jpg", + release_date: "2022-12-15", + title: "Adrenaline", + video: false, + vote_average: 4, + vote_count: 4, + }, + { + adult: false, + backdrop_path: "/a2tys4sD7xzVaogPntGsT1ypVoT.jpg", + genre_ids: [53, 35, 80], + id: 804150, + original_language: "en", + original_title: "Cocaine Bear", + overview: + "Inspired by a true story, an oddball group of cops, criminals, tourists and teens converge in a Georgia forest where a 500-pound black bear goes on a murderous rampage after unintentionally ingesting cocaine.", + popularity: 1175.491, + poster_path: "/gOnmaxHo0412UVr1QM5Nekv1xPi.jpg", + release_date: "2023-02-22", + title: "Cocaine Bear", + video: false, + vote_average: 6.4, + vote_count: 878, + }, + { + adult: false, + backdrop_path: "/54IXMMEQKlkPXHqPExWy98UBmtE.jpg", + genre_ids: [27], + id: 1008005, + original_language: "es", + original_title: "La niña de la comunión", + overview: + "Spain, late 1980s. Newcomer Sara tries to fit in with the other teens in this tight-knit small town in the province of Tarragona. If only she were more like her extroverted best friend, Rebe. They go out one night at a nightclub, on the way home, they come upon a little girl holding a doll, dressed for her first communion. And that's when the nightmare begins.", + popularity: 1154.3, + poster_path: "/sP6AO11a7jWgsmT9T8j9EGIWAaZ.jpg", + release_date: "2023-02-10", + title: "The Communion Girl", + video: false, + vote_average: 6.5, + vote_count: 58, + }, + { + adult: false, + backdrop_path: "/tFaC1Fb1sv1dALB0i9Avi76MHmn.jpg", + genre_ids: [10751, 28, 12], + id: 946310, + original_language: "nl", + original_title: "De Piraten van Hiernaast II: De Ninja's van de Overkant", + overview: + "The pirates feel right at home in Sandborough, but the atmosphere cools right down when the ninjas come to live in the street. After all, pirates and ninjas are sworn enemies! While pirate captain Hector Blunderbuss struggles to get rid of his new neighbours, son Billy and ninja daughter Yuka become friends. The pirates challenge the ninjas to the ultimate battle at the village's annual hexathlon. Who will win the match? Ninjas are faster and more agile of course, but pirates are the best cheats in all of the seven seas...", + popularity: 1159.928, + poster_path: "/uDsvma9dAwnDPVuCFi99YpWvBk0.jpg", + release_date: "2022-04-20", + title: "Pirates Down the Street II: The Ninjas from Across", + video: false, + vote_average: 6.4, + vote_count: 22, + }, + { + adult: false, + backdrop_path: "/rPSJAElGxOTko1zK6uIlYnTMFxN.jpg", + genre_ids: [80], + id: 1104040, + original_language: "en", + original_title: "Gangs of Lagos", + overview: + "A group of friends who each have to navigate their own destiny, growing up on the bustling streets and neighborhood of Isale Eko, Lagos.", + popularity: 1138.252, + poster_path: "/nGwFsB6EXUCr21wzPgtP5juZPSv.jpg", + release_date: "2023-04-07", + title: "Gangs of Lagos", + video: false, + vote_average: 5.6, + vote_count: 21, + }, + { + adult: false, + backdrop_path: "/eSVu1FvGPy86TDo4hQbpuHx55DJ.jpg", + genre_ids: [878, 12, 53, 28], + id: 700391, + original_language: "en", + original_title: "65", + overview: + "65 million years ago, the only 2 survivors of a spaceship from Somaris that crash-landed on Earth must fend off dinosaurs and reach the escape vessel in time before an imminent asteroid strike threatens to destroy the planet.", + popularity: 1077.09, + poster_path: "/rzRb63TldOKdKydCvWJM8B6EkPM.jpg", + release_date: "2023-03-02", + title: "65", + video: false, + vote_average: 6.3, + vote_count: 763, + }, + { + adult: false, + backdrop_path: "/5Y5pz0NX7SZS9036I733F7uNcwK.jpg", + genre_ids: [27, 53], + id: 758323, + original_language: "en", + original_title: "The Pope's Exorcist", + overview: + "Father Gabriele Amorth, Chief Exorcist of the Vatican, investigates a young boy's terrifying possession and ends up uncovering a centuries-old conspiracy the Vatican has desperately tried to keep hidden.", + popularity: 1073.229, + poster_path: "/9JBEPLTPSm0d1mbEcLxULjJq9Eh.jpg", + release_date: "2023-04-05", + title: "The Pope's Exorcist", + video: false, + vote_average: 6.5, + vote_count: 143, + }, + { + adult: false, + backdrop_path: "/m1fgGSLK0WvRpzM1AmZu38m0Tx8.jpg", + genre_ids: [28], + id: 842945, + original_language: "en", + original_title: "Supercell", + overview: + "Good-hearted teenager William always lived in hope of following in his late father’s footsteps and becoming a storm chaser. His father’s legacy has now been turned into a storm-chasing tourist business, managed by the greedy and reckless Zane Rogers, who is now using William as the main attraction to lead a group of unsuspecting adventurers deep into the eye of the most dangerous supercell ever seen.", + popularity: 942.178, + poster_path: "/gbGHezV6yrhua0KfAgwrknSOiIY.jpg", + release_date: "2023-03-17", + title: "Supercell", + video: false, + vote_average: 6.3, + vote_count: 125, + }, + { + adult: false, + backdrop_path: "/tYcmm8XtzRdcT6kliCbHuWwLCwB.jpg", + genre_ids: [28, 80, 53], + id: 849869, + original_language: "ko", + original_title: "길복순", + overview: + "At work, she's a renowned assassin. At home, she's a single mom to a teenage daughter. Killing? That's easy. It's parenting that's the hard part.", + popularity: 958.517, + poster_path: "/taYgn3RRpCGlTGdaGQvnSIOzXFy.jpg", + release_date: "2023-02-17", + title: "Kill Boksoon", + video: false, + vote_average: 6.8, + vote_count: 184, + }, + { + adult: false, + backdrop_path: "/eNJhWy7xFzR74SYaSJHqJZuroDm.jpg", + genre_ids: [28, 878], + id: 1033219, + original_language: "en", + original_title: "Attack on Titan", + overview: + "As viable water is depleted on Earth, a mission is sent to Saturn's moon Titan to retrieve sustainable H2O reserves from its alien inhabitants. But just as the humans acquire the precious resource, they are attacked by Titan rebels, who don't trust that the Earthlings will leave in peace.", + popularity: 897.66, + poster_path: "/qNz4l8UgTkD8rlqiKZ556pCJ9iO.jpg", + release_date: "2022-09-30", + title: "Attack on Titan", + video: false, + vote_average: 6.1, + vote_count: 105, + }, +]; + +export const searchFixture = [ + { + adult: false, + backdrop_path: "/yAzC0pV1e1BzXA5NyWsGsRggOM5.jpg", + genre_ids: [35, 28], + id: 10535, + original_language: "en", + original_title: "Spy Hard", + overview: + "The evil Gen. Rancor has his sights set on world domination, and only one man can stop him: Dick Steele, also known as Agent WD-40. Rancor needs to obtain a computer circuit for the missile that he is planning to fire, so Steele teams up with Veronique Ukrinsky, a KGB agent whose father designed the chip. Together they try to locate the evil mastermind's headquarters, where Veronique's father and several other hostages are being held.", + popularity: 3.1465, + poster_path: "/yoegp9XEG4YCbJJKR0wvm0BGYpG.jpg", + release_date: "1996-05-24", + title: "Spy Hard", + video: false, + vote_average: 5.42, + vote_count: 751, + }, + { + adult: false, + backdrop_path: "/bmuTrxbPcr1nKCsV5YWWJi73PGR.jpg", + genre_ids: [10751, 28, 35, 12, 878], + id: 12279, + original_language: "en", + original_title: "Spy Kids 3-D: Game Over", + overview: + "Carmen's caught in a virtual reality game designed by the Kids' new nemesis, the Toymaker. It's up to Juni to save his sister, and ultimately the world.", + popularity: 3.5825, + poster_path: "/buA8dN4zLNr0dYBeKfHfMnEfdLE.jpg", + release_date: "2003-07-25", + title: "Spy Kids 3-D: Game Over", + video: false, + vote_average: 5.141, + vote_count: 2347, + }, + { + adult: false, + backdrop_path: "/9CqzJn0nLaDNM6QWfGkuox2Oi93.jpg", + genre_ids: [35, 28, 12], + id: 454992, + original_language: "en", + original_title: "The Spy Who Dumped Me", + overview: + "A couple of thirtysomething best friends unwittingly become entangled in an international conspiracy when one’s ex-boyfriend shows up at their apartment with a team of deadly assassins on his trail.", + popularity: 3.4911, + poster_path: "/szEKivKPOdRyogROoQIldwbMl.jpg", + release_date: "2018-08-02", + title: "The Spy Who Dumped Me", + video: false, + vote_average: 6.376, + vote_count: 2343, + }, + { + adult: false, + backdrop_path: "/qyHc9Fxgt6Szby7fmItRd9fpM6V.jpg", + genre_ids: [36, 18, 53], + id: 399121, + original_language: "fr", + original_title: "J'accuse", + overview: + "In 1894, French Captain Alfred Dreyfus is wrongfully convicted of treason and sentenced to life imprisonment at the Devil’s Island penal colony.", + popularity: 3.4689, + poster_path: "/kqlzmKVBpGz7OMWfOXlK4y1m9Uz.jpg", + release_date: "2019-09-30", + title: "An Officer and a Spy", + video: false, + vote_average: 7.073, + vote_count: 1582, + }, + { + adult: false, + backdrop_path: "/7Hfatv0lZkMyxw8UBZmiK2qSXDR.jpg", + genre_ids: [28, 80, 53], + id: 1535, + original_language: "en", + original_title: "Spy Game", + overview: + "On the day of his retirement, a veteran CIA agent learns that his former protégé has been arrested in China, is sentenced to die the next morning in Beijing, and that the CIA is considering letting that happen to avoid an international scandal.", + popularity: 4.1635, + poster_path: "/6y8M1rxjKofQCRKKe6xeV91K2Fc.jpg", + release_date: "2001-11-18", + title: "Spy Game", + video: false, + vote_average: 6.916, + vote_count: 2320, + }, + { + adult: false, + backdrop_path: "/w7TUrUUo9pKcgY7PCdm2LK6XbZB.jpg", + genre_ids: [28, 35, 10751], + id: 23172, + original_language: "en", + original_title: "The Spy Next Door", + overview: + "Former CIA spy Bob Ho takes on his toughest assignment to date: looking after his girlfriend's three kids, who haven't exactly warmed to their mom's beau. And when one of the youngsters accidentally downloads a top-secret formula, Bob's longtime nemesis, a Russian terrorist, pays a visit to the family.", + popularity: 3.2022, + poster_path: "/nJJrceb2xHGIA0irADX0JvWSIHT.jpg", + release_date: "2010-01-15", + title: "The Spy Next Door", + video: false, + vote_average: 5.877, + vote_count: 1393, + }, + { + adult: false, + backdrop_path: "/3pIqd1hgZ2xqzWEyiYp4blqE9Fi.jpg", + genre_ids: [53, 36, 18], + id: 522241, + original_language: "en", + original_title: "The Courier", + overview: + "Cold War spy Greville Wynne and his Russian source try to put an end to the Cuban Missile Crisis.", + popularity: 3.1275, + poster_path: "/zFIjKtZrzhmc7HecdFXXjsLR2Ig.jpg", + release_date: "2020-01-24", + title: "The Courier", + video: false, + vote_average: 7.03, + vote_count: 1307, + }, + { + adult: false, + backdrop_path: "/3COCz2DMRNefY4a3lfUHrNj4tl0.jpg", + genre_ids: [10751, 35, 28, 12, 18, 14, 878], + id: 56288, + original_language: "en", + original_title: "Spy Kids: All the Time in the World", + overview: + "Eight years after the third film, the OSS has become the world's top spy agency, while the Spy Kids department has since become defunct. Retired spy Marissa is called back into action, and to bond with her new stepchildren Rebecca and Cecil, she invites them along to stop the evil Timekeeper from taking over the world.", + popularity: 3.576, + poster_path: "/9pUVarhiXfCu8amjmyadzJJJITi.jpg", + release_date: "2011-08-18", + title: "Spy Kids: All the Time in the World", + video: false, + vote_average: 4.7, + vote_count: 1050, + }, + { + adult: false, + backdrop_path: "/nMEGEPQIyS30KIJpZETGpluFquJ.jpg", + genre_ids: [28, 80, 35], + id: 412988, + original_language: "en", + original_title: "The Happytime Murders", + overview: + "In a world where humans coexist with puppets—who are seen as lesser citizens—cast members of a beloved 1990s children's television series begin getting murdered one by one. Puppet Phil Philips, an ex-LAPD detective-turned-private eye, takes on the case at the request of his old boss in order to assist his former partner, Detective Connie Edwards.", + popularity: 2.9624, + poster_path: "/rWxkur51srfVnMn2QOFjE7mbq6h.jpg", + release_date: "2018-08-22", + title: "The Happytime Murders", + video: false, + vote_average: 5.8, + vote_count: 1077, + }, + { + adult: false, + backdrop_path: "/ulkWS7Atv0vv33KVCSAmNizAmJd.jpg", + genre_ids: [878, 53], + id: 615469, + original_language: "en", + original_title: "Spiderhead", + overview: + "A prisoner in a state-of-the-art penitentiary begins to question the purpose of the emotion-controlling drugs he's testing for a pharmaceutical genius.", + popularity: 3.6025, + poster_path: "/7COPO5B9AOKIB4sEkvNu0wfve3c.jpg", + release_date: "2022-06-17", + title: "Spiderhead", + video: false, + vote_average: 5.796, + vote_count: 1722, + }, + { + adult: false, + backdrop_path: "/aPVq0ZPNEzBRU3dWSQ8YNoJHk61.jpg", + genre_ids: [28, 35], + id: 331313, + original_language: "en", + original_title: "Keeping Up with the Joneses", + overview: + "An ordinary suburban couple finds it’s not easy keeping up with the Joneses – their impossibly gorgeous and ultra-sophisticated new neighbors – especially when they discover that Mr. and Mrs. “Jones” are covert operatives.", + popularity: 2.7691, + poster_path: "/yvWcTrRCzE4C2hkd2wV4erPuKCn.jpg", + release_date: "2016-10-20", + title: "Keeping Up with the Joneses", + video: false, + vote_average: 6.086, + vote_count: 2129, + }, + { + adult: false, + backdrop_path: "/7e9tSzepYj8ZNbVF9P7KJ3BLlxD.jpg", + genre_ids: [36, 18, 10752], + id: 1079244, + original_language: "en", + original_title: "Bonhoeffer: Pastor. Spy. Assassin", + overview: + "As the world teeters on the brink of annihilation, Dietrich Bonhoeffer joins a deadly plot to assassinate Hitler, risking his faith and fate to save millions of Jews from genocide.", + popularity: 2.5652, + poster_path: "/9WtImcI6k9XOcnPmSeN2Kzpf4ay.jpg", + release_date: "2024-11-20", + title: "Bonhoeffer: Pastor. Spy. Assassin", + video: false, + vote_average: 6.716, + vote_count: 74, + }, + { + adult: false, + backdrop_path: null, + genre_ids: [35, 28], + id: 262866, + original_language: "en", + original_title: "In Security", + overview: + "The owners of a failing security company start robbing houses to boost business.", + popularity: 1.9718, + poster_path: "/kn8xtTjqTR8CgvLjDAgp5z8Eifj.jpg", + release_date: "2014-04-28", + title: "In Security", + video: false, + vote_average: 5, + vote_count: 18, + }, + { + adult: false, + backdrop_path: "/zVVYFN9OjiSn43RVYrzgCOu7y6r.jpg", + genre_ids: [18, 9648, 53], + id: 9613, + original_language: "en", + original_title: "Spider", + overview: + "A mentally disturbed man takes residence in a halfway house. His mind gradually slips back into the realm created by his illness, where he replays a key part of his childhood.", + popularity: 2.1297, + poster_path: "/zvm4WuYxTiGkRagRqHUey0meRQL.jpg", + release_date: "2002-11-06", + title: "Spider", + video: false, + vote_average: 6.6, + vote_count: 924, + }, + { + adult: false, + backdrop_path: "/6fKEw0I2FTD5FLOQ5q7L1tqf876.jpg", + genre_ids: [10751, 35, 12, 878], + id: 790493, + original_language: "en", + original_title: "Spy Kids: Armageddon", + overview: + "When the children of the world’s greatest secret agents unwittingly help a powerful game developer unleash a computer virus that gives him control of all technology, they must become spies themselves to save their parents and the world.", + popularity: 2.491, + poster_path: "/vd8YdaH7dzeIMGTNwQinlSiA1gV.jpg", + release_date: "2023-09-22", + title: "Spy Kids: Armageddon", + video: false, + vote_average: 6, + vote_count: 168, + }, + { + adult: false, + backdrop_path: "/zZEJu3db4Ld4urK5klPvQxkWzaX.jpg", + genre_ids: [12, 14, 18, 35, 10402], + id: 6116, + original_language: "en", + original_title: "Spice World", + overview: + "World famous pop group the Spice Girls zip around London in their luxurious double decker tour bus having various adventures and performing for their fans.", + popularity: 2.5111, + poster_path: "/vKiAnV1QAGIJP38vatwCZp4dCrH.jpg", + release_date: "1997-12-18", + title: "Spice World", + video: false, + vote_average: 4.9, + vote_count: 501, + }, + { + adult: false, + backdrop_path: "/zEuXnDVLny3CHdoSk3WpvCsRyZD.jpg", + genre_ids: [35, 10402], + id: 975225, + original_language: "en", + original_title: "Spinal Tap II: The End Continues", + overview: + "The now estranged bandmates of Spinal Tap are forced to reunite for one final concert, hoping it will solidify their place in the pantheon of rock 'n' roll.", + popularity: 2.3306, + poster_path: "/c8Vg1HOlAN8pNPkUrH7Mov1KOLk.jpg", + release_date: "2025-09-12", + title: "Spinal Tap II: The End Continues", + video: false, + vote_average: 6.377, + vote_count: 77, + }, + { + adult: false, + backdrop_path: "/xOqg1A4dUqYbjhzhchSgSpaXdYk.jpg", + genre_ids: [18, 10752, 53], + id: 688301, + original_language: "ja", + original_title: "スパイの妻", + overview: + "It’s 1940, and the population of Japan is divided over its entry into World War II. Satoko, the wife of a fabric merchant, is devoted to her husband but is beginning to suspect he’s up to something. Soon she allows herself to be drawn into a game in which she enigmatically conceals her intentions.", + popularity: 2.4677, + poster_path: "/seFjT7jDZA1j1YsbMyeNTuCRe5d.jpg", + release_date: "2020-10-16", + title: "Wife of a Spy", + video: false, + vote_average: 6.7, + vote_count: 99, + }, + { + adult: false, + backdrop_path: "/9RJGN6CaCFOzs9H1yie2aPxLUoX.jpg", + genre_ids: [35, 12], + id: 9080, + original_language: "en", + original_title: "Spies Like Us", + overview: + "Two bumbling government employees think they are U.S. spies, only to discover that they are actually decoys for nuclear war.", + popularity: 1.5368, + poster_path: "/s0Sx8nd9Irq0aCPbsN78s0DYVlG.jpg", + release_date: "1985-12-06", + title: "Spies Like Us", + video: false, + vote_average: 6.188, + vote_count: 693, + }, + { + adult: false, + backdrop_path: "/fAiadlNo44vVLagveGa51O4soRB.jpg", + genre_ids: [53, 18, 36], + id: 616558, + original_language: "no", + original_title: "Spionen", + overview: + "Sonja Wigert, Scandinavia's most acclaimed female movie star, enlists as a spy for Swedish intelligence but ends up becoming entangled with the German Reichskommissar Terboven.", + popularity: 1.6986, + poster_path: "/cJdrqRggDs6t5iKTsmg7XwViWE0.jpg", + release_date: "2019-10-18", + title: "The Spy", + video: false, + vote_average: 6.1, + vote_count: 79, + }, +]; From 99c3f0e1f24d5a585686b12581e6054450dfe141 Mon Sep 17 00:00:00 2001 From: boyeon Date: Wed, 1 Apr 2026 18:48:42 +0900 Subject: [PATCH 15/77] =?UTF-8?q?fix:=20=EA=B2=80=EC=83=89=20=EB=8D=94?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EB=B2=84=ED=8A=BC=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=9D=B8=EA=B8=B0=20api=20=ED=98=B8=EC=B6=9C=20=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: stardusty-lab stardusty-lab@users.noreply.github.com --- src/main.ts | 113 +++++++++++++++++++++++++++++++++------------------- 1 file changed, 72 insertions(+), 41 deletions(-) diff --git a/src/main.ts b/src/main.ts index 61de3bb8ef..a5de80d282 100644 --- a/src/main.ts +++ b/src/main.ts @@ -59,7 +59,6 @@ const removeMovieList = () => { }; const renderMovies = (movies: Movies): void => { - console.log(movies); const movieList = document.querySelector("#movie-list"); movies.results.forEach((movie: Movie) => { @@ -105,7 +104,6 @@ const renderTopRatedMovie = (movies: Movies) => { const renderSkeleton = () => { const skeleton = document.querySelector("#skeleton"); - console.log(skeleton); if (!skeleton) return; const skeletonTemplate = @@ -138,6 +136,58 @@ const condition = { page: 1, }; +const searchMovies = () => { + const search = getSearchParams("search"); + + (async () => { + const movies = await getSearchMovie({ + page: condition.page, + query: search, + }); + + const topRatedMovie = + document.querySelector(".top-rated-movie"); + if (!topRatedMovie) return null; + topRatedMovie.style.display = "none"; + + const background = document.querySelector( + ".background-container", + ); + if (!background) return null; + background.style.backgroundColor = "transparent"; + background.style.height = "auto"; + + const overlay = document.querySelector(".overlay"); + if (!overlay) return null; + overlay.style.background = ""; + overlay.style.display = "none"; + + const movieListTitle = document.querySelector("#movie-list-title"); + if (!movieListTitle) return null; + movieListTitle.textContent = `"${search}" 검색 결과`; + + if (movies.results.length) { + renderMovies(movies); + } else { + renderEmpty(); + } + })(); +}; + +function navigate(path: string) { + history.pushState(null, "", path); +} + +function getSearchParams(queryKey: string) { + const params = new URLSearchParams(location.search); + return params.get(queryKey); +} + +function hasSearchParams(queryKey: string): boolean { + const params = new URLSearchParams(location.search); + return params.get(queryKey) === null ? false : true; +} + addEventListener("load", async () => { (async () => { const topRatedMovies = await getTopRatedMovie(); @@ -155,57 +205,29 @@ addEventListener("load", async () => { const moreButton = document.querySelector("#more-button"); moreButton?.addEventListener("click", () => { condition.page += 1; + const isSearchParams = hasSearchParams("search"); + + if (isSearchParams) { + searchMovies(); + return; + } (async () => { const movies = await getMoviePopular({ page: condition.page }); renderMovies(movies); })(); }); - const searchMovies = () => { + const searchButton = document.querySelector("#search-button"); + searchButton?.addEventListener("click", () => { const searchInput = document.querySelector("#search-input"); if (!searchInput) return; const search = searchInput.value || ""; + condition.page = 1; + navigate(`/?search=${search}`); - (async () => { - const movies = await getSearchMovie({ - page: condition.page, - query: search, - }); - - const topRatedMovie = - document.querySelector(".top-rated-movie"); - if (!topRatedMovie) return null; - topRatedMovie.style.display = "none"; - - const background = document.querySelector( - ".background-container", - ); - if (!background) return null; - background.style.backgroundColor = "transparent"; - background.style.height = "auto"; - - const overlay = document.querySelector(".overlay"); - if (!overlay) return null; - overlay.style.background = ""; - overlay.style.display = "none"; - - const movieListTitle = document.querySelector("#movie-list-title"); - if (!movieListTitle) return null; - movieListTitle.textContent = `"${search}" 검색 결과`; - - removeMovieList(); - if (movies.results.length) { - renderMovies(movies); - } else { - renderEmpty(); - } - })(); - }; - - const searchButton = document.querySelector("#search-button"); - searchButton?.addEventListener("click", () => { + removeMovieList(); searchMovies(); }); @@ -213,6 +235,15 @@ addEventListener("load", async () => { if (!searchInput) return; searchInput?.addEventListener("keyup", (e: KeyboardEvent) => { if (e.key === "Enter") { + const searchInput = + document.querySelector("#search-input"); + if (!searchInput) return; + + const search = searchInput.value || ""; + condition.page = 1; + navigate(`/?search=${search}`); + + removeMovieList(); searchMovies(); } }); From 9877f633e562da28df2ed4ec4f3a561957af3a3e Mon Sep 17 00:00:00 2001 From: boyeon Date: Wed, 1 Apr 2026 19:34:15 +0900 Subject: [PATCH 16/77] =?UTF-8?q?refactor:=20=EB=A0=8C=EB=8D=94=EB=A7=81?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=ED=95=A8=EC=88=98=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=EC=9D=98=20=EA=B3=B5=ED=86=B5=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: stardusty-lab stardusty-lab@users.noreply.github.com --- src/main.ts | 213 ++++++++------------------------------------------ src/render.ts | 147 ++++++++++++++++++++++++++++++++++ src/router.ts | 13 +++ 3 files changed, 191 insertions(+), 182 deletions(-) create mode 100644 src/render.ts create mode 100644 src/router.ts diff --git a/src/main.ts b/src/main.ts index a5de80d282..bd10036b7c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,192 +1,59 @@ +import { navigate, getSearchParams, hasSearchParams } from "./router"; + import { getMoviePopular, getTopRatedMovie, getSearchMovie, } from "./service/api"; -import { Movie, Movies } from "./service/dto"; - -const createMovieNode = (movie: Movie): DocumentFragment | null => { - const movieTemplate = - document.querySelector(`#movie-template`); - - if (!movieTemplate) return null; - - const movieFragment = movieTemplate.content.cloneNode( - true, - ) as DocumentFragment; - - const movieItem = movieFragment.querySelector("li"); - - if (!movieItem) return null; - movieItem.dataset.movieId = String(movie.id); - - const thumbnail = movieFragment.querySelector(".thumbnail"); - if (!thumbnail) return null; - thumbnail.src = - `https://media.themoviedb.org/t/p/w220_and_h330_face` + movie.poster_path; - thumbnail.alt = movie.title; - - const itemDesc = movieFragment.querySelector(".item-desc"); - - const rate = itemDesc?.querySelector("span"); - if (!rate) return null; - rate.textContent = movie.vote_average.toString(); - - const title = itemDesc?.querySelector("strong"); - if (!title) return null; - title.textContent = movie.title; - - return movieFragment; -}; - -const hideMoreButton = () => { - const moreButton = document.querySelector("#more-button"); - if (!moreButton) return null; - moreButton.style.display = "none"; -}; - -const showMoreButton = () => { - const moreButton = document.querySelector("#more-button"); - if (!moreButton) return null; - moreButton.style.display = "block"; -}; - -const removeMovieList = () => { - const movieList = document.querySelector("#movie-list"); - if (!movieList) return; - - movieList.innerHTML = ""; -}; - -const renderMovies = (movies: Movies): void => { - const movieList = document.querySelector("#movie-list"); - - movies.results.forEach((movie: Movie) => { - const movieNode = createMovieNode(movie); - if (movieNode) { - movieList?.appendChild(movieNode); - } - }); - - if (movies.page === movies.total_pages) { - hideMoreButton(); - } else { - showMoreButton(); - } -}; - -const renderEmpty = () => { - const thumbnailList = document.querySelector(".thumbnail-list"); - if (!thumbnailList) return; - const empty = '

              검색 결과가 없습니다.

              '; - thumbnailList.innerHTML = empty; - hideMoreButton(); -}; - -const renderTopRatedMovie = (movies: Movies) => { - const topRatedMovie = movies.results[0]; - const topRatedContainer = document.querySelector(".top-rated-container"); - if (!topRatedContainer) return null; - - const overlay = document.querySelector(".overlay"); - if (!overlay) return null; - overlay.style.background = `url(${`https://media.themoviedb.org/t/p/w1920_and_h800_multi_faces` + topRatedMovie.backdrop_path}) center center no-repeat`; - - const rateValue = topRatedContainer.querySelector(".rate-value"); - if (!rateValue) return null; - rateValue.textContent = topRatedMovie.vote_average.toString(); - - const title = topRatedContainer.querySelector(".title"); - if (!title) return null; - title.textContent = topRatedMovie.title; -}; - -const renderSkeleton = () => { - const skeleton = document.querySelector("#skeleton"); - if (!skeleton) return; - - const skeletonTemplate = - document.querySelector("#movie-template"); - if (!skeletonTemplate) return null; - - for (let i = 0; i < 20; i++) { - const skeletonCloneNode = skeletonTemplate.content.cloneNode( - true, - ) as DocumentFragment; - if (!skeletonCloneNode) return null; - - skeleton.appendChild(skeletonCloneNode); - } -}; - -const removeSkeleton = () => { - const skeleton = document.querySelector("#skeleton"); - if (!skeleton) return; - - skeleton.classList.add("animation"); - - setTimeout(() => { - skeleton.classList.remove("animation"); - skeleton.replaceChildren(); - }, 3000); -}; +import { + renderTopRatedMovie, + renderMovieList, + renderNoResult, + renderSkeleton, + removeTopRatedMovie, + removeMovieList, + removeSkeleton, +} from "./render"; const condition = { page: 1, }; -const searchMovies = () => { - const search = getSearchParams("search"); +const runSearch = () => { + const search = getSearchParams("search") as string; (async () => { const movies = await getSearchMovie({ page: condition.page, - query: search, + query: search || "", }); - const topRatedMovie = - document.querySelector(".top-rated-movie"); - if (!topRatedMovie) return null; - topRatedMovie.style.display = "none"; - - const background = document.querySelector( - ".background-container", - ); - if (!background) return null; - background.style.backgroundColor = "transparent"; - background.style.height = "auto"; - - const overlay = document.querySelector(".overlay"); - if (!overlay) return null; - overlay.style.background = ""; - overlay.style.display = "none"; + removeTopRatedMovie(); const movieListTitle = document.querySelector("#movie-list-title"); if (!movieListTitle) return null; movieListTitle.textContent = `"${search}" 검색 결과`; if (movies.results.length) { - renderMovies(movies); + renderMovieList(movies); } else { - renderEmpty(); + renderNoResult(); } })(); }; -function navigate(path: string) { - history.pushState(null, "", path); -} +const handleSearch = () => { + const searchInput = document.querySelector("#search-input"); + if (!searchInput) return; -function getSearchParams(queryKey: string) { - const params = new URLSearchParams(location.search); - return params.get(queryKey); -} + const search = searchInput.value || ""; + condition.page = 1; + navigate(`/?search=${search}`); -function hasSearchParams(queryKey: string): boolean { - const params = new URLSearchParams(location.search); - return params.get(queryKey) === null ? false : true; -} + removeMovieList(); + runSearch(); +}; addEventListener("load", async () => { (async () => { @@ -198,7 +65,7 @@ addEventListener("load", async () => { renderSkeleton(); const movies = await getMoviePopular({ page: condition.page }); - renderMovies(movies); + renderMovieList(movies); removeSkeleton(); })(); @@ -208,43 +75,25 @@ addEventListener("load", async () => { const isSearchParams = hasSearchParams("search"); if (isSearchParams) { - searchMovies(); + runSearch(); return; } (async () => { const movies = await getMoviePopular({ page: condition.page }); - renderMovies(movies); + renderMovieList(movies); })(); }); const searchButton = document.querySelector("#search-button"); searchButton?.addEventListener("click", () => { - const searchInput = - document.querySelector("#search-input"); - if (!searchInput) return; - - const search = searchInput.value || ""; - condition.page = 1; - navigate(`/?search=${search}`); - - removeMovieList(); - searchMovies(); + handleSearch(); }); const searchInput = document.querySelector("#search-input"); if (!searchInput) return; searchInput?.addEventListener("keyup", (e: KeyboardEvent) => { if (e.key === "Enter") { - const searchInput = - document.querySelector("#search-input"); - if (!searchInput) return; - - const search = searchInput.value || ""; - condition.page = 1; - navigate(`/?search=${search}`); - - removeMovieList(); - searchMovies(); + handleSearch(); } }); }); diff --git a/src/render.ts b/src/render.ts new file mode 100644 index 0000000000..d13f57d5ca --- /dev/null +++ b/src/render.ts @@ -0,0 +1,147 @@ +import { Movie, Movies } from "./service/dto"; + +const createMovieNode = (movie: Movie): DocumentFragment | null => { + const movieTemplate = + document.querySelector(`#movie-template`); + if (!movieTemplate) return null; + + const movieFragment = movieTemplate.content.cloneNode( + true, + ) as DocumentFragment; + + const movieItem = movieFragment.querySelector("li"); + if (!movieItem) return null; + movieItem.dataset.movieId = String(movie.id); + + const thumbnail = movieFragment.querySelector(".thumbnail"); + if (!thumbnail) return null; + thumbnail.src = + `https://media.themoviedb.org/t/p/w220_and_h330_face` + movie.poster_path; + thumbnail.alt = movie.title; + + const itemDesc = movieFragment.querySelector(".item-desc"); + + const rate = itemDesc?.querySelector("span"); + if (!rate) return null; + rate.textContent = movie.vote_average.toString(); + + const title = itemDesc?.querySelector("strong"); + if (!title) return null; + title.textContent = movie.title; + + return movieFragment; +}; + +const hideMoreButton = () => { + const moreButton = document.querySelector("#more-button"); + if (!moreButton) return null; + + moreButton.style.display = "none"; +}; + +const showMoreButton = () => { + const moreButton = document.querySelector("#more-button"); + if (!moreButton) return null; + + moreButton.style.display = "block"; +}; + +export const renderTopRatedMovie = (movies: Movies) => { + const topRatedMovie = movies.results[0]; + const topRatedContainer = document.querySelector(".top-rated-container"); + if (!topRatedContainer) return null; + + const overlay = document.querySelector(".overlay"); + if (!overlay) return null; + overlay.style.background = `url(${`https://media.themoviedb.org/t/p/w1920_and_h800_multi_faces` + topRatedMovie.backdrop_path}) center center no-repeat`; + + const rateValue = topRatedContainer.querySelector(".rate-value"); + if (!rateValue) return null; + rateValue.textContent = topRatedMovie.vote_average.toString(); + + const title = topRatedContainer.querySelector(".title"); + if (!title) return null; + title.textContent = topRatedMovie.title; +}; + +export const renderMovieList = (movies: Movies): void => { + const movieList = document.querySelector("#movie-list"); + + movies.results.forEach((movie: Movie) => { + const movieNode = createMovieNode(movie); + if (movieNode) { + movieList?.appendChild(movieNode); + } + }); + + if (movies.page === movies.total_pages) { + hideMoreButton(); + } else { + showMoreButton(); + } +}; + +export const renderNoResult = () => { + const thumbnailList = document.querySelector(".thumbnail-list"); + if (!thumbnailList) return; + const empty = '

              검색 결과가 없습니다.

              '; + thumbnailList.innerHTML = empty; + + hideMoreButton(); +}; + +export const renderSkeleton = () => { + const skeleton = document.querySelector("#skeleton"); + if (!skeleton) return; + + const skeletonTemplate = + document.querySelector("#movie-template"); + if (!skeletonTemplate) return null; + + for (let i = 0; i < 20; i++) { + const skeletonCloneNode = skeletonTemplate.content.cloneNode( + true, + ) as DocumentFragment; + if (!skeletonCloneNode) return null; + + skeleton.appendChild(skeletonCloneNode); + } +}; + +export const removeTopRatedMovie = () => { + const topRatedMovie = + document.querySelector(".top-rated-movie"); + if (!topRatedMovie) return null; + topRatedMovie.style.display = "none"; + + const background = document.querySelector( + ".background-container", + ); + if (!background) return null; + background.style.backgroundColor = "transparent"; + background.style.height = "auto"; + + const overlay = document.querySelector(".overlay"); + if (!overlay) return null; + overlay.style.background = ""; + overlay.style.display = "none"; +}; + +export const removeMovieList = () => { + const movieList = document.querySelector("#movie-list"); + if (!movieList) return; + + movieList.replaceChildren(); +}; + +export const removeSkeleton = () => { + const skeleton = document.querySelector("#skeleton"); + if (!skeleton) return; + + skeleton.classList.add("animation"); + + setTimeout(() => { + skeleton.classList.remove("animation"); + skeleton.replaceChildren(); + }, 3000); +}; diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000000..b44e6d391f --- /dev/null +++ b/src/router.ts @@ -0,0 +1,13 @@ +export const navigate = (path: string) => { + history.pushState(null, "", path); +}; + +export const getSearchParams = (queryKey: string) => { + const params = new URLSearchParams(location.search); + return params.get(queryKey); +}; + +export const hasSearchParams = (queryKey: string): boolean => { + const params = new URLSearchParams(location.search); + return params.get(queryKey) === null ? false : true; +}; From 29f0b850d18ae5d687293b2cbb7cbf8a8739a22d Mon Sep 17 00:00:00 2001 From: boyeon Date: Thu, 2 Apr 2026 11:15:55 +0900 Subject: [PATCH 17/77] =?UTF-8?q?refactor:=20=EB=A0=8C=EB=8D=94=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EB=B3=84=EB=A1=9C=20=ED=8C=8C=EC=9D=BC=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: stardusty-lab --- public/styles/skeleton.css | 2 +- src/main.ts | 13 +-- src/render.ts | 147 ------------------------------- src/renders/moreButton.ts | 13 +++ src/renders/movieList.ts | 67 ++++++++++++++ src/renders/skeleton.ts | 29 ++++++ src/renders/topRatedMovie.ts | 38 ++++++++ src/{service => services}/api.ts | 0 src/{service => services}/dto.ts | 0 src/{ => utils}/router.ts | 0 10 files changed, 155 insertions(+), 154 deletions(-) delete mode 100644 src/render.ts create mode 100644 src/renders/moreButton.ts create mode 100644 src/renders/movieList.ts create mode 100644 src/renders/skeleton.ts create mode 100644 src/renders/topRatedMovie.ts rename src/{service => services}/api.ts (100%) rename src/{service => services}/dto.ts (100%) rename src/{ => utils}/router.ts (100%) diff --git a/public/styles/skeleton.css b/public/styles/skeleton.css index 6215964c02..67c377a771 100644 --- a/public/styles/skeleton.css +++ b/public/styles/skeleton.css @@ -19,7 +19,7 @@ .container section .skeleton-list li .dummy-img { width: 200px; height: 300px; - background: rgba(0, 0, 0, 0.5); + background: #f0f0f0; border-radius: 8px; margin-bottom: 4px; } diff --git a/src/main.ts b/src/main.ts index bd10036b7c..1f14eb0180 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,20 +1,21 @@ -import { navigate, getSearchParams, hasSearchParams } from "./router"; +import { navigate, getSearchParams, hasSearchParams } from "./utils/router"; import { getMoviePopular, getTopRatedMovie, getSearchMovie, -} from "./service/api"; +} from "./services/api"; import { renderTopRatedMovie, + removeTopRatedMovie, +} from "./renders/topRatedMovie"; +import { renderMovieList, renderNoResult, - renderSkeleton, - removeTopRatedMovie, removeMovieList, - removeSkeleton, -} from "./render"; +} from "./renders/movieList"; +import { renderSkeleton, removeSkeleton } from "./renders/skeleton"; const condition = { page: 1, diff --git a/src/render.ts b/src/render.ts deleted file mode 100644 index d13f57d5ca..0000000000 --- a/src/render.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { Movie, Movies } from "./service/dto"; - -const createMovieNode = (movie: Movie): DocumentFragment | null => { - const movieTemplate = - document.querySelector(`#movie-template`); - if (!movieTemplate) return null; - - const movieFragment = movieTemplate.content.cloneNode( - true, - ) as DocumentFragment; - - const movieItem = movieFragment.querySelector("li"); - if (!movieItem) return null; - movieItem.dataset.movieId = String(movie.id); - - const thumbnail = movieFragment.querySelector(".thumbnail"); - if (!thumbnail) return null; - thumbnail.src = - `https://media.themoviedb.org/t/p/w220_and_h330_face` + movie.poster_path; - thumbnail.alt = movie.title; - - const itemDesc = movieFragment.querySelector(".item-desc"); - - const rate = itemDesc?.querySelector("span"); - if (!rate) return null; - rate.textContent = movie.vote_average.toString(); - - const title = itemDesc?.querySelector("strong"); - if (!title) return null; - title.textContent = movie.title; - - return movieFragment; -}; - -const hideMoreButton = () => { - const moreButton = document.querySelector("#more-button"); - if (!moreButton) return null; - - moreButton.style.display = "none"; -}; - -const showMoreButton = () => { - const moreButton = document.querySelector("#more-button"); - if (!moreButton) return null; - - moreButton.style.display = "block"; -}; - -export const renderTopRatedMovie = (movies: Movies) => { - const topRatedMovie = movies.results[0]; - const topRatedContainer = document.querySelector(".top-rated-container"); - if (!topRatedContainer) return null; - - const overlay = document.querySelector(".overlay"); - if (!overlay) return null; - overlay.style.background = `url(${`https://media.themoviedb.org/t/p/w1920_and_h800_multi_faces` + topRatedMovie.backdrop_path}) center center no-repeat`; - - const rateValue = topRatedContainer.querySelector(".rate-value"); - if (!rateValue) return null; - rateValue.textContent = topRatedMovie.vote_average.toString(); - - const title = topRatedContainer.querySelector(".title"); - if (!title) return null; - title.textContent = topRatedMovie.title; -}; - -export const renderMovieList = (movies: Movies): void => { - const movieList = document.querySelector("#movie-list"); - - movies.results.forEach((movie: Movie) => { - const movieNode = createMovieNode(movie); - if (movieNode) { - movieList?.appendChild(movieNode); - } - }); - - if (movies.page === movies.total_pages) { - hideMoreButton(); - } else { - showMoreButton(); - } -}; - -export const renderNoResult = () => { - const thumbnailList = document.querySelector(".thumbnail-list"); - if (!thumbnailList) return; - const empty = '

              검색 결과가 없습니다.

              '; - thumbnailList.innerHTML = empty; - - hideMoreButton(); -}; - -export const renderSkeleton = () => { - const skeleton = document.querySelector("#skeleton"); - if (!skeleton) return; - - const skeletonTemplate = - document.querySelector("#movie-template"); - if (!skeletonTemplate) return null; - - for (let i = 0; i < 20; i++) { - const skeletonCloneNode = skeletonTemplate.content.cloneNode( - true, - ) as DocumentFragment; - if (!skeletonCloneNode) return null; - - skeleton.appendChild(skeletonCloneNode); - } -}; - -export const removeTopRatedMovie = () => { - const topRatedMovie = - document.querySelector(".top-rated-movie"); - if (!topRatedMovie) return null; - topRatedMovie.style.display = "none"; - - const background = document.querySelector( - ".background-container", - ); - if (!background) return null; - background.style.backgroundColor = "transparent"; - background.style.height = "auto"; - - const overlay = document.querySelector(".overlay"); - if (!overlay) return null; - overlay.style.background = ""; - overlay.style.display = "none"; -}; - -export const removeMovieList = () => { - const movieList = document.querySelector("#movie-list"); - if (!movieList) return; - - movieList.replaceChildren(); -}; - -export const removeSkeleton = () => { - const skeleton = document.querySelector("#skeleton"); - if (!skeleton) return; - - skeleton.classList.add("animation"); - - setTimeout(() => { - skeleton.classList.remove("animation"); - skeleton.replaceChildren(); - }, 3000); -}; diff --git a/src/renders/moreButton.ts b/src/renders/moreButton.ts new file mode 100644 index 0000000000..57af2a83a9 --- /dev/null +++ b/src/renders/moreButton.ts @@ -0,0 +1,13 @@ +export const renderMoreButton = () => { + const moreButton = document.querySelector("#more-button"); + if (!moreButton) return null; + + moreButton.style.display = "block"; +}; + +export const removeMoreButton = () => { + const moreButton = document.querySelector("#more-button"); + if (!moreButton) return null; + + moreButton.style.display = "none"; +}; diff --git a/src/renders/movieList.ts b/src/renders/movieList.ts new file mode 100644 index 0000000000..98c073b5ca --- /dev/null +++ b/src/renders/movieList.ts @@ -0,0 +1,67 @@ +import { Movie, Movies } from "../services/dto"; +import { removeMoreButton, renderMoreButton } from "./moreButton"; + +const createMovieNode = (movie: Movie): DocumentFragment | null => { + const movieTemplate = + document.querySelector(`#movie-template`); + if (!movieTemplate) return null; + + const movieFragment = movieTemplate.content.cloneNode( + true, + ) as DocumentFragment; + + const movieItem = movieFragment.querySelector("li"); + if (!movieItem) return null; + movieItem.dataset.movieId = String(movie.id); + + const thumbnail = movieFragment.querySelector(".thumbnail"); + if (!thumbnail) return null; + thumbnail.src = + `https://media.themoviedb.org/t/p/w220_and_h330_face` + movie.poster_path; + thumbnail.alt = movie.title; + + const itemDesc = movieFragment.querySelector(".item-desc"); + + const rate = itemDesc?.querySelector("span"); + if (!rate) return null; + rate.textContent = movie.vote_average.toString(); + + const title = itemDesc?.querySelector("strong"); + if (!title) return null; + title.textContent = movie.title; + + return movieFragment; +}; + +export const renderMovieList = (movies: Movies): void => { + const movieList = document.querySelector("#movie-list"); + + movies.results.forEach((movie: Movie) => { + const movieNode = createMovieNode(movie); + if (movieNode) { + movieList?.appendChild(movieNode); + } + }); + + if (movies.page === movies.total_pages) { + removeMoreButton(); + } else { + renderMoreButton(); + } +}; + +export const renderNoResult = () => { + const thumbnailList = document.querySelector(".thumbnail-list"); + if (!thumbnailList) return; + const empty = '

              검색 결과가 없습니다.

              '; + thumbnailList.innerHTML = empty; + + removeMoreButton(); +}; + +export const removeMovieList = () => { + const movieList = document.querySelector("#movie-list"); + if (!movieList) return; + + movieList.replaceChildren(); +}; diff --git a/src/renders/skeleton.ts b/src/renders/skeleton.ts new file mode 100644 index 0000000000..b06c8c9dfc --- /dev/null +++ b/src/renders/skeleton.ts @@ -0,0 +1,29 @@ +export const renderSkeleton = () => { + const skeleton = document.querySelector("#skeleton"); + if (!skeleton) return; + + const skeletonTemplate = + document.querySelector("#movie-template"); + if (!skeletonTemplate) return null; + + for (let i = 0; i < 20; i++) { + const skeletonCloneNode = skeletonTemplate.content.cloneNode( + true, + ) as DocumentFragment; + if (!skeletonCloneNode) return null; + + skeleton.appendChild(skeletonCloneNode); + } +}; + +export const removeSkeleton = () => { + const skeleton = document.querySelector("#skeleton"); + if (!skeleton) return; + + skeleton.classList.add("animation"); + + setTimeout(() => { + skeleton.classList.remove("animation"); + skeleton.replaceChildren(); + }, 3000); +}; diff --git a/src/renders/topRatedMovie.ts b/src/renders/topRatedMovie.ts new file mode 100644 index 0000000000..e2f3db2164 --- /dev/null +++ b/src/renders/topRatedMovie.ts @@ -0,0 +1,38 @@ +import { Movies } from "../services/dto"; + +export const renderTopRatedMovie = (movies: Movies) => { + const topRatedMovie = movies.results[0]; + const topRatedContainer = document.querySelector(".top-rated-container"); + if (!topRatedContainer) return null; + + const overlay = document.querySelector(".overlay"); + if (!overlay) return null; + overlay.style.background = `url(${`https://media.themoviedb.org/t/p/w1920_and_h800_multi_faces` + topRatedMovie.backdrop_path}) center center no-repeat`; + + const rateValue = topRatedContainer.querySelector(".rate-value"); + if (!rateValue) return null; + rateValue.textContent = topRatedMovie.vote_average.toString(); + + const title = topRatedContainer.querySelector(".title"); + if (!title) return null; + title.textContent = topRatedMovie.title; +}; + +export const removeTopRatedMovie = () => { + const topRatedMovie = + document.querySelector(".top-rated-movie"); + if (!topRatedMovie) return null; + topRatedMovie.style.display = "none"; + + const background = document.querySelector( + ".background-container", + ); + if (!background) return null; + background.style.backgroundColor = "transparent"; + background.style.height = "auto"; + + const overlay = document.querySelector(".overlay"); + if (!overlay) return null; + overlay.style.background = ""; + overlay.style.display = "none"; +}; diff --git a/src/service/api.ts b/src/services/api.ts similarity index 100% rename from src/service/api.ts rename to src/services/api.ts diff --git a/src/service/dto.ts b/src/services/dto.ts similarity index 100% rename from src/service/dto.ts rename to src/services/dto.ts diff --git a/src/router.ts b/src/utils/router.ts similarity index 100% rename from src/router.ts rename to src/utils/router.ts From 1c62e42db6272d1cb8eae4e00c061a18c972a195 Mon Sep 17 00:00:00 2001 From: boyeon Date: Thu, 2 Apr 2026 13:07:10 +0900 Subject: [PATCH 18/77] =?UTF-8?q?refactor:=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=EA=B0=92=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=EB=A1=9C=20=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: stardusty-lab --- src/main.ts | 18 ++++++++++-------- src/states/PageState.ts | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 8 deletions(-) create mode 100644 src/states/PageState.ts diff --git a/src/main.ts b/src/main.ts index 1f14eb0180..db4fabff3f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,17 +16,17 @@ import { removeMovieList, } from "./renders/movieList"; import { renderSkeleton, removeSkeleton } from "./renders/skeleton"; +import PageState from "./states/PageState"; -const condition = { - page: 1, -}; +const pageState = new PageState(); const runSearch = () => { const search = getSearchParams("search") as string; (async () => { + const page = pageState.getPage(); const movies = await getSearchMovie({ - page: condition.page, + page, query: search || "", }); @@ -49,7 +49,7 @@ const handleSearch = () => { if (!searchInput) return; const search = searchInput.value || ""; - condition.page = 1; + pageState.resetPage(); navigate(`/?search=${search}`); removeMovieList(); @@ -65,14 +65,15 @@ addEventListener("load", async () => { (async () => { renderSkeleton(); - const movies = await getMoviePopular({ page: condition.page }); + const page = pageState.getPage(); + const movies = await getMoviePopular({ page }); renderMovieList(movies); removeSkeleton(); })(); const moreButton = document.querySelector("#more-button"); moreButton?.addEventListener("click", () => { - condition.page += 1; + pageState.increamentPage(); const isSearchParams = hasSearchParams("search"); if (isSearchParams) { @@ -80,7 +81,8 @@ addEventListener("load", async () => { return; } (async () => { - const movies = await getMoviePopular({ page: condition.page }); + const page = pageState.getPage(); + const movies = await getMoviePopular({ page }); renderMovieList(movies); })(); }); diff --git a/src/states/PageState.ts b/src/states/PageState.ts new file mode 100644 index 0000000000..960a68106b --- /dev/null +++ b/src/states/PageState.ts @@ -0,0 +1,21 @@ +class PageState { + #page: number; + + constructor() { + this.#page = 1; + } + + getPage() { + return this.#page; + } + + increamentPage() { + this.#page += 1; + } + + resetPage() { + this.#page = 1; + } +} + +export default PageState; From fcf82b305e24afaa900909aad085ca53dceec06a Mon Sep 17 00:00:00 2001 From: boyeon Date: Thu, 2 Apr 2026 13:28:04 +0900 Subject: [PATCH 19/77] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=EC=96=B4?= =?UTF-8?q?=EA=B0=80=20=EC=9E=85=EB=A0=A5=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EC=9D=84=20=EA=B2=BD=EC=9A=B0=20=EA=B2=80=EC=83=89=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=9D=B4=20=EC=88=98=ED=96=89=EB=90=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=ED=95=98=EB=8A=94=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: stardusty-lab stardusty-lab@users.noreply.github.com --- README.md | 4 +++- src/main.ts | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6979665c67..a947ed225f 100644 --- a/README.md +++ b/README.md @@ -21,4 +21,6 @@ FE 레벨1 영화 리뷰 미션입니다. ## 오류 처리 -- [ ] +- [x] 검색어가 입력되지 않았을 때 검색 기능이 수행되지 않도록 한다. +- [ ] api 반환 값이 200이 아닐 경우 에러 메시지를 모달로 보여준다. +- [ ] api가 정상적으로 동작하지 않을 경우 에러 메시지를 모달로 보여준다. diff --git a/src/main.ts b/src/main.ts index db4fabff3f..6bc7d9a11a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -49,6 +49,11 @@ const handleSearch = () => { if (!searchInput) return; const search = searchInput.value || ""; + if (!search.length) { + searchInput.focus(); + return; + } + pageState.resetPage(); navigate(`/?search=${search}`); From 3c5b5a33c63423f3a6e2c69eb6084719091934f0 Mon Sep 17 00:00:00 2001 From: boyeon Date: Thu, 2 Apr 2026 15:40:43 +0900 Subject: [PATCH 20/77] =?UTF-8?q?feat:=20api=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?=EA=B0=92=EC=9D=B4=20200=EC=9D=B4=20=EC=95=84=EB=8B=90=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=20=EC=97=90=EB=9F=AC=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=EB=A5=BC=20=EB=9D=84=EC=9A=B0=EB=8A=94=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: stardusty-lab stardusty-lab@users.noreply.github.com --- README.md | 2 +- cypress/e2e/error-handling.cy.ts | 42 ++++++++++++++++++++++++++ cypress/e2e/movie-list-rendering.cy.ts | 2 +- cypress/e2e/movie-search.cy.ts | 2 +- src/main.ts | 40 +++++++++++++++++++++--- src/services/api.ts | 17 +++++++++-- test/api.test.ts | 2 +- 7 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 cypress/e2e/error-handling.cy.ts diff --git a/README.md b/README.md index a947ed225f..cd4d664088 100644 --- a/README.md +++ b/README.md @@ -22,5 +22,5 @@ FE 레벨1 영화 리뷰 미션입니다. ## 오류 처리 - [x] 검색어가 입력되지 않았을 때 검색 기능이 수행되지 않도록 한다. -- [ ] api 반환 값이 200이 아닐 경우 에러 메시지를 모달로 보여준다. +- [x] api 반환 값이 200이 아닐 경우 에러 메시지를 모달로 보여준다. - [ ] api가 정상적으로 동작하지 않을 경우 에러 메시지를 모달로 보여준다. diff --git a/cypress/e2e/error-handling.cy.ts b/cypress/e2e/error-handling.cy.ts new file mode 100644 index 0000000000..f2469d881e --- /dev/null +++ b/cypress/e2e/error-handling.cy.ts @@ -0,0 +1,42 @@ +import { moviesFixture } from "../../test/fixtures"; + +describe("오류 대응 테스트", () => { + beforeEach(() => { + cy.intercept("GET", "**/movie/popular?page=1", { + statusCode: 200, + body: { + page: 1, + results: [...moviesFixture], + total_pages: 2, + total_results: 40, + }, + }).as("getPopularPage1"); + + cy.intercept("GET", "**/movie/popular?page=2", { + statusCode: 400, + body: { + success: false, + status_code: 22, + status_message: + "Invalid page: Pages start at 1 and max at 500. They are expected to be an integer.", + }, + }).as("getInvalidPopularPage"); + + cy.visit("localhost:5173"); + cy.wait("@getPopularPage1"); + }); + + it("검색어가 입력되지 않을 경우 검색버튼을 눌러도 검색 기능을 수행하지 않고 입력 부분을 다시 포커싱한다.", () => { + cy.get("#search-button").click(); + + cy.focused().should("have.id", "search-input"); + }); + + it("정상적인 페이지 범위를 벗어난 페이지를 요청했을 때 alter 경고 메시지가 뜬다.", () => { + const alertSpy = cy.stub(); + cy.on("window:alert", alertSpy); + + cy.get("#more-button").click(); + cy.wrap(alertSpy).should("have.been.called"); + }); +}); diff --git a/cypress/e2e/movie-list-rendering.cy.ts b/cypress/e2e/movie-list-rendering.cy.ts index 45ed6c1bda..78fccdde5e 100644 --- a/cypress/e2e/movie-list-rendering.cy.ts +++ b/cypress/e2e/movie-list-rendering.cy.ts @@ -1,6 +1,6 @@ import { moviesFixture } from "../../test/fixtures"; -describe("template spec", () => { +describe("영화 목록 조회 기능 테스트", () => { beforeEach(() => { cy.intercept("GET", "**/movie/popular?page=1", { statusCode: 200, diff --git a/cypress/e2e/movie-search.cy.ts b/cypress/e2e/movie-search.cy.ts index 2f259d3951..c293833f73 100644 --- a/cypress/e2e/movie-search.cy.ts +++ b/cypress/e2e/movie-search.cy.ts @@ -1,6 +1,6 @@ import { searchFixture } from "../../test/fixtures"; -describe("template spec", () => { +describe("영화 검색 기능 테스트", () => { beforeEach(() => { cy.intercept( "GET", diff --git a/src/main.ts b/src/main.ts index 6bc7d9a11a..46ccac4cd8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ import { navigate, getSearchParams, hasSearchParams } from "./utils/router"; import { + ApiError, getMoviePopular, getTopRatedMovie, getSearchMovie, @@ -61,18 +62,36 @@ const handleSearch = () => { runSearch(); }; +const errorTryCatch = async (api: Function, errorCallback: Function) => { + try { + return await api(); + } catch (e) { + errorCallback(e); + } +}; + addEventListener("load", async () => { (async () => { const topRatedMovies = await getTopRatedMovie(); + renderTopRatedMovie(topRatedMovies); })(); (async () => { renderSkeleton(); - const page = pageState.getPage(); - const movies = await getMoviePopular({ page }); - renderMovieList(movies); + const movies = await errorTryCatch( + async () => await getMoviePopular({ page }), + async (e: ApiError) => { + if (e.status_code == 22) { + alert("페이지 제대로 넣어라"); + return; + } + alert("범용 에러 메시지"); + }, + ); + + if (movies) renderMovieList(movies); removeSkeleton(); })(); @@ -87,8 +106,19 @@ addEventListener("load", async () => { } (async () => { const page = pageState.getPage(); - const movies = await getMoviePopular({ page }); - renderMovieList(movies); + + const movies = await errorTryCatch( + async () => await getMoviePopular({ page }), + async (e: ApiError) => { + if (e.status_code == 22) { + alert("페이지 제대로 넣어라"); + return; + } + alert("범용 에러 메시지"); + }, + ); + + if (movies) renderMovieList(movies); })(); }); diff --git a/src/services/api.ts b/src/services/api.ts index 757bd43b85..e8c4147ad4 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1,6 +1,16 @@ import { apiUrl, apiKey } from "../constants/env"; import { Movies } from "./dto"; +export class ApiError extends Error { + status_code: number; + + constructor(message: string, status_code: number) { + super(message); + this.name = "ApiError"; + this.status_code = status_code; + } +} + export const getMoviePopular = async ({ page, }: { @@ -14,11 +24,14 @@ export const getMoviePopular = async ({ }, }); - return await res.json(); + if (res.ok) return await res.json(); + + const errorBody = await res.json(); + throw new ApiError(errorBody.status_message, errorBody.status_code); }; export const getTopRatedMovie = async () => { - const url = `${apiUrl}/movie/top_rated`; + const url = `${apiUrl}/movie/top_rated?page=-1`; const res = await fetch(url, { method: "get", headers: { diff --git a/test/api.test.ts b/test/api.test.ts index c6fc28a1d1..7c0260d8d9 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from "vitest"; -import { getMoviePopular } from "../src/service/api"; +import { getMoviePopular } from "../src/services/api"; import { moviesFixture } from "./fixtures"; describe("api 함수 호출 테스트", () => { From 4aa9426c4879a5549198efde05d08bac9f0d0a21 Mon Sep 17 00:00:00 2001 From: boyeon Date: Thu, 2 Apr 2026 16:23:59 +0900 Subject: [PATCH 21/77] =?UTF-8?q?style:=20=EA=B2=80=EC=83=89=EC=B0=BD?= =?UTF-8?q?=EA=B3=BC=20=EA=B2=80=EC=83=89=20=EC=9E=85=EB=A0=A5=EC=8B=9C=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EC=97=86=EC=9D=8C=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: stardusty-lab stardusty-lab@users.noreply.github.com --- index.html | 18 ++++++++----- public/images/mascot.png | Bin 0 -> 4296 bytes public/images/search.png | Bin 0 -> 380 bytes public/styles/main.css | 5 +++- public/styles/message-box.css | 17 ++++++++++++ public/styles/search.css | 47 ++++++++++++++++++++++++++++++++++ src/renders/movieList.ts | 16 +++++++++--- src/services/api.ts | 2 +- 8 files changed, 93 insertions(+), 12 deletions(-) create mode 100644 public/images/mascot.png create mode 100644 public/images/search.png create mode 100644 public/styles/message-box.css create mode 100644 public/styles/search.css diff --git a/index.html b/index.html index 84dcb37751..6176c4a3eb 100644 --- a/index.html +++ b/index.html @@ -9,21 +9,26 @@ + + 영화 리뷰
              - -
              +

              - MovieList + MovieList

              - - - + +
              + +
              @@ -39,6 +44,7 @@

              지금 인기 있는 영화

              +
              -
                -
                  + + +
                  diff --git a/public/styles/skeleton.css b/public/styles/skeleton.css index 67c377a771..5905246b44 100644 --- a/public/styles/skeleton.css +++ b/public/styles/skeleton.css @@ -1,30 +1,42 @@ -.container section { - min-height: 1000px; - position: relative; +.dummy-img { + width: 200px; + height: 300px; + background: #f0f0f0; + border-radius: 8px; + margin-bottom: 4px; + animation: skeleton-pulse 1.5s ease-in-out infinite; } -.container section .thumbnail-list { - min-height: 800px; +.skeleton-line { + height: 16px; + border-radius: 4px; + background: #f0f0f0; + animation: skeleton-pulse 1.5s ease-in-out infinite; } -.container section .skeleton-list { - position: absolute; - top: 54.4px; - left: 0; + +.skeleton-line.rate { + width: 48px; + margin-bottom: 8px; } -.container section .skeleton-list li .thumbnail { - display: none; +.skeleton-line { + width: 120px; } -.container section .skeleton-list li .dummy-img { - width: 200px; - height: 300px; - background: #f0f0f0; - border-radius: 8px; - margin-bottom: 4px; +@keyframes skeleton-pulse { + 0% { + opacity: 0.55; + } + + 50% { + opacity: 1; + } + + 100% { + opacity: 0.55; + } } -.container section .skeleton-list.animation { - transition: opacity 3s; - opacity: 0; +[hidden] { + display: none !important; } diff --git a/src/main.ts b/src/main.ts index d37022b943..3cee1a4bec 100644 --- a/src/main.ts +++ b/src/main.ts @@ -66,6 +66,7 @@ const loadPopularMovies = async () => { const loadSearchMovies = async () => { try { + renderSkeleton(); const search = getSearchParams("search") as string; const page = searchPageState.getPage() + 1; @@ -89,10 +90,12 @@ const loadSearchMovies = async () => { } } catch (e) { showErrorAlert(e); + } finally { + removeSkeleton(); } }; -const loadMoreMovies = async () => { +const loadMoreMovies = () => { const isSearchParams = hasSearchParams("search"); if (isSearchParams) { diff --git a/src/renders/movieList.ts b/src/renders/movieList.ts index 3203f85c62..817f3ed4f8 100644 --- a/src/renders/movieList.ts +++ b/src/renders/movieList.ts @@ -34,7 +34,9 @@ const createMovieNode = (movie: Movie): DocumentFragment | null => { }; export const renderMovieList = (movies: Movies): void => { - const movieList = document.querySelector("#movie-list"); + const movieList = document.querySelector("#movie-list"); + if (!movieList) return; + movieList.hidden = false; movies.results.forEach((movie: Movie) => { const movieNode = createMovieNode(movie); @@ -58,9 +60,12 @@ export const renderNoResult = () => { }; export const removeMovieList = () => { - const movieList = document.querySelector("#movie-list"); - movieList?.replaceChildren(); - const noResult = document.querySelector("#no-result"); noResult?.replaceChildren(); + + const movieList = document.querySelector("#movie-list"); + movieList?.replaceChildren(); + if (movieList) { + movieList.hidden = true; + } }; diff --git a/src/renders/skeleton.ts b/src/renders/skeleton.ts index b06c8c9dfc..f721eef3a4 100644 --- a/src/renders/skeleton.ts +++ b/src/renders/skeleton.ts @@ -1,29 +1,11 @@ export const renderSkeleton = () => { - const skeleton = document.querySelector("#skeleton"); + const skeleton = document.querySelector("#skeleton"); if (!skeleton) return; - - const skeletonTemplate = - document.querySelector("#movie-template"); - if (!skeletonTemplate) return null; - - for (let i = 0; i < 20; i++) { - const skeletonCloneNode = skeletonTemplate.content.cloneNode( - true, - ) as DocumentFragment; - if (!skeletonCloneNode) return null; - - skeleton.appendChild(skeletonCloneNode); - } + skeleton.hidden = false; }; export const removeSkeleton = () => { - const skeleton = document.querySelector("#skeleton"); + const skeleton = document.querySelector("#skeleton"); if (!skeleton) return; - - skeleton.classList.add("animation"); - - setTimeout(() => { - skeleton.classList.remove("animation"); - skeleton.replaceChildren(); - }, 3000); + skeleton.hidden = true; }; From 7c71154b22cf5b04e9ecf632ab66af7980be9725 Mon Sep 17 00:00:00 2001 From: boyeon Date: Thu, 9 Apr 2026 11:19:22 +0900 Subject: [PATCH 40/77] =?UTF-8?q?docs:=202=EB=8B=A8=EA=B3=84=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A0=95=EB=B3=B4=20&=20UI/UX=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=ED=95=98=EA=B8=B0=20=EB=AF=B8=EC=85=98=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cd4d664088..a40ff5be79 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ -# 1단계 - 영화 목록 불러오기 +# 영화 리뷰 - 웹 앱 FE 레벨1 영화 리뷰 미션입니다. -## 영화 목록 조회 기능 +## 1단계 - 영화 목록 불러오기 + +### 영화 목록 조회 기능 - [x] 페이지 상단에 평점이 가장 높은 영화를 출력한다. - [x] 페이지 번호에 따른 영상목록을 불러온다. @@ -12,15 +14,39 @@ FE 레벨1 영화 리뷰 미션입니다. - [x] 더보기 버튼 클릭 시 영화 목록을 추가한다. - [x] 영화 목록의 끝에 도달한 경우에는 더보기 버튼을 출력하지 않는다. -## 영화 검색 기능 +### 영화 검색 기능 - [x] 화면 상단의 검색창에서 입력을 할 수 있다. - [x] 검색버튼을 클릭하면 검색어를 기준으로 영화를 필터링한다. - [x] 검색결과를 노출시킬때 'XXX 검색 결과'를 출력한다. - [x] 검색결과가 없으면 없다는 안내메세지를 출력한다. -## 오류 처리 +### 오류 처리 - [x] 검색어가 입력되지 않았을 때 검색 기능이 수행되지 않도록 한다. - [x] api 반환 값이 200이 아닐 경우 에러 메시지를 모달로 보여준다. - [ ] api가 정상적으로 동작하지 않을 경우 에러 메시지를 모달로 보여준다. + +## 2단계 - 상세정보 & UI/UX 개선 + +### 영화 상세 정보 조회 + +- [ ] 해당 id를 가진 영화의 상세 정보를 불러온다. +- [ ] 상세 정보를 모달창으로 렌더링 한다. +- [ ] 키보드의 ESC 키를 누르면 모달 창을 닫는다. + +### 별점 매기기 + +- [ ] 영화 별점을 매긴다. (별점은 5개이고 한개당 2점) + - 2점: 최악이예요 + - 4점: 별로예요 + - 6점: 보통이에요 + - 8점: 재미있어요 + - 10점: 명작이에요 +- [ ] 사용자가 매긴 별점은 로컬스토리지에 저장한다. + +### UI⁄UX 개선하기 + +- [ ] 브라우저 화면의 끝에 도달하면 영화 20개를 자동으로 로드한다. (무한 스크롤) +- [ ] 태블릿 화면에서 영화 목록과 모달 레이아웃이 반응형으로 동작하도록 한다. +- [ ] 모바일 화면에서 영화 목록과 모달 레이아웃이 반응형으로 동작하도록 한다. From 27d39bd4a4a3ff8281786bb6eeeb4632f479a5d2 Mon Sep 17 00:00:00 2001 From: boyeon Date: Thu, 9 Apr 2026 15:13:46 +0900 Subject: [PATCH 41/77] =?UTF-8?q?refactor:=20=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=9D=84=20controller=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/movieLoad.ts | 104 +++++++++++++++++++++++++++ src/controllers/search.ts | 20 ++++++ src/main.ts | 136 ++--------------------------------- src/utils/error.ts | 10 +++ 4 files changed, 138 insertions(+), 132 deletions(-) create mode 100644 src/controllers/movieLoad.ts create mode 100644 src/controllers/search.ts create mode 100644 src/utils/error.ts diff --git a/src/controllers/movieLoad.ts b/src/controllers/movieLoad.ts new file mode 100644 index 0000000000..f988da7e7b --- /dev/null +++ b/src/controllers/movieLoad.ts @@ -0,0 +1,104 @@ +import { + getTopRatedMovies, + getPopularMovies, + getSearchMovies, +} from "../services/api"; + +import { + renderTopRatedMovie, + removeTopRatedMovie, +} from "../renders/topRatedMovie"; +import { + removeMovieList, + renderMovieList, + renderNoResult, +} from "../renders/movieList"; +import { renderSkeleton, removeSkeleton } from "../renders/skeleton"; + +import { updateMoreButton } from "../renders/moreButton"; +import PageState from "../states/PageState"; +import { getSearchParams, hasSearchParams } from "../utils/router"; +import { showError } from "../utils/error"; + +const popularPageState = new PageState(); +const searchPageState = new PageState(); + +export const loadTopRatedMovie = async () => { + try { + const topRatedMovies = await getTopRatedMovies(); + const topRatedMovie = topRatedMovies.results[0]; + + if (!topRatedMovie) return; + + renderTopRatedMovie(topRatedMovie); + } catch (e) { + showError(e); + } +}; + +export const loadPopularMovies = async () => { + try { + renderSkeleton(); + const page = popularPageState.getPage() + 1; + const movies = await getPopularMovies({ page }); + + if (movies) { + renderMovieList(movies); + updateMoreButton(movies.page, movies.total_pages); + popularPageState.incrementPage(); + } + } catch (e) { + showError(e); + } finally { + removeSkeleton(); + } +}; + +export const loadSearchMovies = async ({ + reset = false, +}: { reset?: boolean } = {}) => { + if (reset) { + searchPageState.resetPage(); + removeMovieList(); + } + + try { + renderSkeleton(); + const search = getSearchParams("search") as string; + + const page = searchPageState.getPage() + 1; + const movies = await getSearchMovies({ + page, + query: search || "", + }); + + removeTopRatedMovie(); + + const movieListTitle = document.querySelector("#movie-list-title"); + if (!movieListTitle) return null; + movieListTitle.textContent = `"${search}" 검색 결과`; + + if (movies.results.length) { + renderMovieList(movies); + updateMoreButton(movies.page, movies.total_pages); + searchPageState.incrementPage(); + } else { + renderNoResult(); + } + } catch (e) { + showError(e); + } finally { + removeSkeleton(); + } +}; + +export const loadMovieList = () => { + const isSearchParams = hasSearchParams("search"); + + if (isSearchParams) { + loadSearchMovies(); + return; + } + + loadPopularMovies(); +}; diff --git a/src/controllers/search.ts b/src/controllers/search.ts new file mode 100644 index 0000000000..95630f8370 --- /dev/null +++ b/src/controllers/search.ts @@ -0,0 +1,20 @@ +import { baseUrl } from "../constants/env"; +import { navigate } from "../utils/router"; +import { loadSearchMovies } from "./movieLoad"; + +export const handleSearch = () => { + const searchInput = document.querySelector("#search-input"); + if (!searchInput) return; + + const search = searchInput.value || ""; + if (!search.length) { + searchInput.focus(); + return; + } + + const searchUrl = new URL(baseUrl, window.location.origin); + searchUrl.searchParams.set("search", search); + navigate(`${searchUrl.pathname}${searchUrl.search}`); + + loadSearchMovies({ reset: true }); +}; diff --git a/src/main.ts b/src/main.ts index 3cee1a4bec..b5b29c9cd4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,130 +1,7 @@ -import { navigate, getSearchParams, hasSearchParams } from "./utils/router"; - -import { - ApiError, - getPopularMovies, - getTopRatedMovies, - getSearchMovies, -} from "./services/api"; - -import { - renderTopRatedMovie, - removeTopRatedMovie, -} from "./renders/topRatedMovie"; -import { - renderMovieList, - renderNoResult, - removeMovieList, -} from "./renders/movieList"; -import { renderSkeleton, removeSkeleton } from "./renders/skeleton"; -import PageState from "./states/PageState"; -import { updateMoreButton } from "./renders/moreButton"; import { baseUrl } from "./constants/env"; +import { handleSearch } from "./controllers/search"; -const popularPageState = new PageState(); -const searchPageState = new PageState(); - -const showErrorAlert = (error: unknown) => { - if (error instanceof ApiError && error.status_code === 22) { - alert("잘못된 페이지 요청입니다."); - return; - } - - alert("영화 정보를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요."); -}; - -const loadTopRatedMovie = async () => { - try { - const topRatedMovies = await getTopRatedMovies(); - const topRatedMovie = topRatedMovies.results[0]; - - if (!topRatedMovie) return; - - renderTopRatedMovie(topRatedMovie); - } catch (e) { - showErrorAlert(e); - } -}; - -const loadPopularMovies = async () => { - try { - renderSkeleton(); - const page = popularPageState.getPage() + 1; - const movies = await getPopularMovies({ page }); - - if (movies) { - renderMovieList(movies); - updateMoreButton(movies.page, movies.total_pages); - popularPageState.incrementPage(); - } - } catch (e) { - showErrorAlert(e); - } finally { - removeSkeleton(); - } -}; - -const loadSearchMovies = async () => { - try { - renderSkeleton(); - const search = getSearchParams("search") as string; - - const page = searchPageState.getPage() + 1; - const movies = await getSearchMovies({ - page, - query: search || "", - }); - - removeTopRatedMovie(); - - const movieListTitle = document.querySelector("#movie-list-title"); - if (!movieListTitle) return null; - movieListTitle.textContent = `"${search}" 검색 결과`; - - if (movies.results.length) { - renderMovieList(movies); - updateMoreButton(movies.page, movies.total_pages); - searchPageState.incrementPage(); - } else { - renderNoResult(); - } - } catch (e) { - showErrorAlert(e); - } finally { - removeSkeleton(); - } -}; - -const loadMoreMovies = () => { - const isSearchParams = hasSearchParams("search"); - - if (isSearchParams) { - loadSearchMovies(); - return; - } - - loadPopularMovies(); -}; - -const handleSearch = () => { - const searchInput = document.querySelector("#search-input"); - if (!searchInput) return; - - const search = searchInput.value || ""; - if (!search.length) { - searchInput.focus(); - return; - } - - searchPageState.resetPage(); - - const searchUrl = new URL(baseUrl, window.location.origin); - searchUrl.searchParams.set("search", search); - navigate(`${searchUrl.pathname}${searchUrl.search}`); - - removeMovieList(); - loadSearchMovies(); -}; +import { loadMovieList, loadTopRatedMovie } from "./controllers/movieLoad"; addEventListener("load", () => { const logo = document.querySelector(".logo"); @@ -146,14 +23,9 @@ addEventListener("load", () => { const moreButton = document.querySelector("#more-button"); moreButton?.addEventListener("click", () => { - loadMoreMovies(); + loadMovieList(); }); - if (hasSearchParams("search")) { - loadSearchMovies(); - return; - } - loadTopRatedMovie(); - loadPopularMovies(); + loadMovieList(); }); diff --git a/src/utils/error.ts b/src/utils/error.ts new file mode 100644 index 0000000000..f159eef337 --- /dev/null +++ b/src/utils/error.ts @@ -0,0 +1,10 @@ +import { ApiError } from "../services/api"; + +export const showError = (error: unknown) => { + if (error instanceof ApiError && error.status_code === 22) { + alert("잘못된 페이지 요청입니다."); + return; + } + + alert("영화 정보를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요."); +}; From 5b607a34101cd6c82be541ba98fed7cb96153b32 Mon Sep 17 00:00:00 2001 From: boyeon Date: Fri, 10 Apr 2026 00:43:32 +0900 Subject: [PATCH 42/77] =?UTF-8?q?feat:=20=EC=98=81=ED=99=94=EC=9D=98=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EB=B6=88?= =?UTF-8?q?=EB=9F=AC=EC=98=A4=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- cypress/e2e/error-handling.cy.ts | 4 ++-- cypress/e2e/movie-list-rendering.cy.ts | 4 ++-- cypress/e2e/movie-search.cy.ts | 24 ++++++++++++++---------- src/main.ts | 16 ++++++++++++++++ src/services/api.ts | 23 +++++++++++++++++++---- src/services/dto.ts | 13 +++++++++++++ 7 files changed, 67 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index a40ff5be79..6b27dda0a7 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ FE 레벨1 영화 리뷰 미션입니다. ### 영화 상세 정보 조회 -- [ ] 해당 id를 가진 영화의 상세 정보를 불러온다. +- [x] 해당 id를 가진 영화의 상세 정보를 불러온다. - [ ] 상세 정보를 모달창으로 렌더링 한다. - [ ] 키보드의 ESC 키를 누르면 모달 창을 닫는다. diff --git a/cypress/e2e/error-handling.cy.ts b/cypress/e2e/error-handling.cy.ts index 6b304a9306..1283774a6c 100644 --- a/cypress/e2e/error-handling.cy.ts +++ b/cypress/e2e/error-handling.cy.ts @@ -2,7 +2,7 @@ import { moviesFixture } from "../../test/fixtures"; describe("오류 대응 테스트", () => { beforeEach(() => { - cy.intercept("GET", "**/movie/popular?page=1", { + cy.intercept("GET", "**/movie/popular?page=1&language=ko-KR", { statusCode: 200, body: { page: 1, @@ -12,7 +12,7 @@ describe("오류 대응 테스트", () => { }, }).as("getPopularPage1"); - cy.intercept("GET", "**/movie/popular?page=2", { + cy.intercept("GET", "**/movie/popular?page=2&language=ko-KR", { statusCode: 400, body: { success: false, diff --git a/cypress/e2e/movie-list-rendering.cy.ts b/cypress/e2e/movie-list-rendering.cy.ts index b8aae4dcef..ce37f61a85 100644 --- a/cypress/e2e/movie-list-rendering.cy.ts +++ b/cypress/e2e/movie-list-rendering.cy.ts @@ -2,7 +2,7 @@ import { moviesFixture } from "../../test/fixtures"; describe("영화 목록 조회 기능 테스트", () => { beforeEach(() => { - cy.intercept("GET", "**/movie/popular?page=1", { + cy.intercept("GET", "**/movie/popular?page=1&language=ko-KR", { statusCode: 200, body: { page: 1, @@ -12,7 +12,7 @@ describe("영화 목록 조회 기능 테스트", () => { }, }).as("getPopularPage1"); - cy.intercept("GET", "**/movie/popular?page=2", { + cy.intercept("GET", "**/movie/popular?page=2&language=ko-KR", { statusCode: 200, body: { page: 2, diff --git a/cypress/e2e/movie-search.cy.ts b/cypress/e2e/movie-search.cy.ts index c293833f73..b418557f70 100644 --- a/cypress/e2e/movie-search.cy.ts +++ b/cypress/e2e/movie-search.cy.ts @@ -4,7 +4,7 @@ describe("영화 검색 기능 테스트", () => { beforeEach(() => { cy.intercept( "GET", - "**/search/movie?page=1&query=%EC%8A%A4%ED%8C%8C%EC%9D%B4", + "**/search/movie?page=1&query=%EC%8A%A4%ED%8C%8C%EC%9D%B4&language=ko-KR", { statusCode: 200, body: { @@ -18,7 +18,7 @@ describe("영화 검색 기능 테스트", () => { cy.intercept( "GET", - "**/search/movie?page=2&query=%EC%8A%A4%ED%8C%8C%EC%9D%B4", + "**/search/movie?page=2&query=%EC%8A%A4%ED%8C%8C%EC%9D%B4&language=ko-KR", { statusCode: 200, body: { @@ -30,15 +30,19 @@ describe("영화 검색 기능 테스트", () => { }, ).as("getSearchPage2"); - cy.intercept("GET", "**/search/movie?page=1&query=%EB%B7%80", { - statusCode: 200, - body: { - page: 1, - results: [], - total_pages: 1, - total_results: 0, + cy.intercept( + "GET", + "**/search/movie?page=1&query=%EB%B7%80&language=ko-KR", + { + statusCode: 200, + body: { + page: 1, + results: [], + total_pages: 1, + total_results: 0, + }, }, - }).as("getSearchNoResult"); + ).as("getSearchNoResult"); cy.visit("localhost:5173"); }); diff --git a/src/main.ts b/src/main.ts index b5b29c9cd4..6cce69ebb1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import { baseUrl } from "./constants/env"; import { handleSearch } from "./controllers/search"; import { loadMovieList, loadTopRatedMovie } from "./controllers/movieLoad"; +import { getDetailMovie } from "./services/api"; addEventListener("load", () => { const logo = document.querySelector(".logo"); @@ -26,6 +27,21 @@ addEventListener("load", () => { loadMovieList(); }); + const movieList = document.querySelector("#movie-list"); + movieList?.addEventListener("click", async (e) => { + const target = e.target as HTMLElement; + const movieItem = target.closest("li"); + + if (!movieItem) return; + + const movieId = movieItem.dataset.movieId; + if (!movieId) return; + console.log(movieId); + + const data = await getDetailMovie(movieId); + console.log(data); + }); + loadTopRatedMovie(); loadMovieList(); }); diff --git a/src/services/api.ts b/src/services/api.ts index 47228f1019..5f7cf91cb8 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1,5 +1,5 @@ import { apiUrl, apiKey } from "../constants/env"; -import { Movies } from "./dto"; +import { MovieDetail, Movies } from "./dto"; export class ApiError extends Error { status_code: number; @@ -16,7 +16,7 @@ export const getPopularMovies = async ({ }: { page: number; }): Promise => { - const url = `${apiUrl}/movie/popular?page=${page}`; + const url = `${apiUrl}/movie/popular?page=${page}&language=ko-KR`; const res = await fetch(url, { method: "get", headers: { @@ -31,7 +31,7 @@ export const getPopularMovies = async ({ }; export const getTopRatedMovies = async () => { - const url = `${apiUrl}/movie/top_rated`; + const url = `${apiUrl}/movie/top_rated?language=ko-KR`; const res = await fetch(url, { method: "get", headers: { @@ -52,7 +52,22 @@ export const getSearchMovies = async ({ page: number; query: string; }): Promise => { - const url = `${apiUrl}/search/movie?page=${page}&query=${query}`; + const url = `${apiUrl}/search/movie?page=${page}&query=${query}&language=ko-KR`; + const res = await fetch(url, { + method: "get", + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (res.ok) return await res.json(); + + const errorBody = await res.json(); + throw new ApiError(errorBody.status_message, errorBody.status_code); +}; + +export const getDetailMovie = async (movieId: string): Promise => { + const url = `${apiUrl}/movie/${movieId}?language=ko-KR`; const res = await fetch(url, { method: "get", headers: { diff --git a/src/services/dto.ts b/src/services/dto.ts index 0593032434..279389a784 100644 --- a/src/services/dto.ts +++ b/src/services/dto.ts @@ -21,3 +21,16 @@ export interface Movies { total_pages: number; total_results: number; } + +export interface MovieDetail { + id: number; + title: string; + poster_path: string | null; + overview: string; + release_date: string; + vote_average: number; + genres: { + id: number; + name: string; + }[]; +} From b78d3ef257de581e451e087815dd5c9c7a6946c7 Mon Sep 17 00:00:00 2001 From: boyeon Date: Sat, 11 Apr 2026 13:08:14 +0900 Subject: [PATCH 43/77] =?UTF-8?q?feat:=20=EC=83=81=EC=84=B8=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=EB=A5=BC=20=EB=AA=A8=EB=8B=AC=EC=B0=BD=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- index.html | 25 +++++++++++++++++++++++++ src/main.ts | 6 +++--- src/renders/movieDetail.ts | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 src/renders/movieDetail.ts diff --git a/README.md b/README.md index 6b27dda0a7..b36da7b163 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ FE 레벨1 영화 리뷰 미션입니다. ### 영화 상세 정보 조회 - [x] 해당 id를 가진 영화의 상세 정보를 불러온다. -- [ ] 상세 정보를 모달창으로 렌더링 한다. +- [x] 상세 정보를 모달창으로 렌더링 한다. - [ ] 키보드의 ESC 키를 누르면 모달 창을 닫는다. ### 별점 매기기 diff --git a/index.html b/index.html index 62f4ff3533..4f17c1e63e 100644 --- a/index.html +++ b/index.html @@ -11,6 +11,7 @@ + 영화 리뷰 @@ -243,6 +244,7 @@

                  지금 인기 있는 영화

                  +
                  @@ -254,6 +256,29 @@

                  지금 인기 있는 영화

                  + + + diff --git a/src/main.ts b/src/main.ts index 6cce69ebb1..52dcfa7ccf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,7 @@ import { handleSearch } from "./controllers/search"; import { loadMovieList, loadTopRatedMovie } from "./controllers/movieLoad"; import { getDetailMovie } from "./services/api"; +import { renderMovieDetail } from "./renders/movieDetail"; addEventListener("load", () => { const logo = document.querySelector(".logo"); @@ -36,10 +37,9 @@ addEventListener("load", () => { const movieId = movieItem.dataset.movieId; if (!movieId) return; - console.log(movieId); - const data = await getDetailMovie(movieId); - console.log(data); + const movieDetail = await getDetailMovie(movieId); + renderMovieDetail(movieDetail); }); loadTopRatedMovie(); diff --git a/src/renders/movieDetail.ts b/src/renders/movieDetail.ts new file mode 100644 index 0000000000..22baabbd9f --- /dev/null +++ b/src/renders/movieDetail.ts @@ -0,0 +1,32 @@ +import { MovieDetail } from "../services/dto"; + +export function renderMovieDetail(movieDetail: MovieDetail) { + const movieModal = document.querySelector("#modalBackground"); + if (!movieModal) return null; + + const modalImage = + movieModal.querySelector(".modal-image img"); + const title = movieModal.querySelector("h2"); + const rate = movieModal.querySelector(".rate span"); + const detail = movieModal.querySelector(".detail"); + const category = movieModal.querySelector(".category"); + + if (modalImage) + modalImage.src = + `https://media.themoviedb.org/t/p/w300_and_h450_face` + + movieDetail.poster_path; + + if (title) title.textContent = movieDetail.title; + + if (rate) rate.textContent = movieDetail.vote_average.toString(); + + if (detail) detail.textContent = movieDetail.overview; + + if (category) { + const releaseYear = new Date(movieDetail.release_date).getFullYear(); + const genres = movieDetail.genres.map((genre) => genre.name).join(", "); + category.textContent = `${releaseYear} · ${genres}`; + } + + movieModal.classList.add("active"); +} From a7b8008df4e273d8e8c61db6fc43d817c1a569d0 Mon Sep 17 00:00:00 2001 From: boyeon Date: Sat, 11 Apr 2026 13:36:18 +0900 Subject: [PATCH 44/77] =?UTF-8?q?feat:=20=EB=AA=A8=EB=8B=AC=EC=B0=BD=20?= =?UTF-8?q?=EB=8B=AB=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?-=20x=20=EB=B2=84=ED=8A=BC=20=EB=88=84=EB=A5=BC=20=EB=95=8C=20-?= =?UTF-8?q?=20ESC=20=ED=82=A4=20=EB=88=84=EB=A5=BC=20=EB=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- index.html | 4 ++-- src/main.ts | 17 ++++++++++++++++- src/renders/movieDetail.ts | 12 ++++++++++-- src/renders/movieModal.ts | 13 +++++++++++++ 5 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 src/renders/movieModal.ts diff --git a/README.md b/README.md index b36da7b163..d216af9ac1 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ FE 레벨1 영화 리뷰 미션입니다. - [x] 해당 id를 가진 영화의 상세 정보를 불러온다. - [x] 상세 정보를 모달창으로 렌더링 한다. -- [ ] 키보드의 ESC 키를 누르면 모달 창을 닫는다. +- [x] 키보드의 ESC 키를 누르면 모달 창을 닫는다. ### 별점 매기기 diff --git a/index.html b/index.html index 4f17c1e63e..260decb4d7 100644 --- a/index.html +++ b/index.html @@ -257,9 +257,9 @@

                  지금 인기 있는 영화

                  - diff --git a/public/styles/main.css b/public/styles/main.css index b7522c2227..0a62f5d423 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -124,6 +124,10 @@ span.rate-value { font-weight: bold; } +.scroll-sentinel { + height: 10px; +} + footer.footer { min-height: 180px; background-color: var(--color-bluegray-80); diff --git a/src/controllers/movieLoad.ts b/src/controllers/movieLoad.ts index f988da7e7b..0266702897 100644 --- a/src/controllers/movieLoad.ts +++ b/src/controllers/movieLoad.ts @@ -15,7 +15,6 @@ import { } from "../renders/movieList"; import { renderSkeleton, removeSkeleton } from "../renders/skeleton"; -import { updateMoreButton } from "../renders/moreButton"; import PageState from "../states/PageState"; import { getSearchParams, hasSearchParams } from "../utils/router"; import { showError } from "../utils/error"; @@ -44,8 +43,8 @@ export const loadPopularMovies = async () => { if (movies) { renderMovieList(movies); - updateMoreButton(movies.page, movies.total_pages); popularPageState.incrementPage(); + popularPageState.setTotalPages(movies.total_pages); } } catch (e) { showError(e); @@ -80,8 +79,8 @@ export const loadSearchMovies = async ({ if (movies.results.length) { renderMovieList(movies); - updateMoreButton(movies.page, movies.total_pages); searchPageState.incrementPage(); + searchPageState.setTotalPages(movies.total_pages); } else { renderNoResult(); } diff --git a/src/main.ts b/src/main.ts index 653888f9cc..184440c5b5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -86,4 +86,17 @@ addEventListener("load", () => { loadTopRatedMovie(); loadMovieList(); + + const movieListObserver = new IntersectionObserver( + (entries) => { + const [sentinelEntry] = entries; + if (!sentinelEntry.isIntersecting) return; + + loadMovieList(); + }, + { rootMargin: "300px" }, + ); + + const sentinel = document.querySelector(".scroll-sentinel"); + if (sentinel) movieListObserver.observe(sentinel); }); diff --git a/src/states/PageState.ts b/src/states/PageState.ts index d0ea4ee7bd..05e23be77f 100644 --- a/src/states/PageState.ts +++ b/src/states/PageState.ts @@ -1,8 +1,10 @@ class PageState { #page: number; + #totalPages: number | null; constructor() { this.#page = 0; + this.#totalPages = null; } getPage() { @@ -13,8 +15,18 @@ class PageState { this.#page += 1; } + setTotalPages(totalPages: number) { + this.#totalPages = totalPages; + } + resetPage() { this.#page = 0; + this.#totalPages = null; + } + + isLastPage() { + if (this.#totalPages === null) return false; + return this.#page >= this.#totalPages; } } From 60972549d77c41bc3f74c8b48f7b3f1058e211a2 Mon Sep 17 00:00:00 2001 From: boyeon Date: Sat, 11 Apr 2026 23:27:20 +0900 Subject: [PATCH 49/77] =?UTF-8?q?refactor:=20=EC=98=81=ED=99=94=20?= =?UTF-8?q?=EB=85=B8=EB=93=9C=20=EC=A1=B0=ED=9A=8C=EC=99=80=20=EA=B0=92=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renders/movieList.ts | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/renders/movieList.ts b/src/renders/movieList.ts index 817f3ed4f8..0cb194ee82 100644 --- a/src/renders/movieList.ts +++ b/src/renders/movieList.ts @@ -9,25 +9,18 @@ const createMovieNode = (movie: Movie): DocumentFragment | null => { const movieFragment = movieTemplate.content.cloneNode( true, ) as DocumentFragment; - const movieItem = movieFragment.querySelector("li"); - if (!movieItem) return null; - movieItem.dataset.movieId = String(movie.id); - const thumbnail = movieFragment.querySelector(".thumbnail"); - if (!thumbnail) return null; + const rate = movieFragment.querySelector(".item-desc span"); + const title = movieFragment.querySelector(".item-desc strong"); + + if (!movieItem || !thumbnail || !rate || !title) return null; + + movieItem.dataset.movieId = String(movie.id); thumbnail.src = `https://media.themoviedb.org/t/p/w220_and_h330_face` + movie.poster_path; thumbnail.alt = movie.title; - - const itemDesc = movieFragment.querySelector(".item-desc"); - - const rate = itemDesc?.querySelector("span"); - if (!rate) return null; rate.textContent = movie.vote_average.toString(); - - const title = itemDesc?.querySelector("strong"); - if (!title) return null; title.textContent = movie.title; return movieFragment; @@ -41,7 +34,7 @@ export const renderMovieList = (movies: Movies): void => { movies.results.forEach((movie: Movie) => { const movieNode = createMovieNode(movie); if (movieNode) { - movieList?.appendChild(movieNode); + movieList.appendChild(movieNode); } }); }; @@ -61,11 +54,13 @@ export const renderNoResult = () => { export const removeMovieList = () => { const noResult = document.querySelector("#no-result"); - noResult?.replaceChildren(); + if (noResult) { + noResult.replaceChildren(); + } const movieList = document.querySelector("#movie-list"); - movieList?.replaceChildren(); if (movieList) { + movieList.replaceChildren(); movieList.hidden = true; } }; From 58a0b2c37ab8983191fe04e3ad79a8f95df81604 Mon Sep 17 00:00:00 2001 From: boyeon Date: Sat, 11 Apr 2026 23:28:55 +0900 Subject: [PATCH 50/77] =?UTF-8?q?refactor:=20=EC=9A=94=EC=86=8C=20?= =?UTF-8?q?=EB=AA=BB=20=EC=B0=BE=EC=9C=BC=EB=A9=B4=20=EC=A2=85=EB=A3=8C=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=20=EC=B0=BE=EC=9D=80=20=EC=9A=94=EC=86=8C=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renders/topRatedMovie.ts | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/src/renders/topRatedMovie.ts b/src/renders/topRatedMovie.ts index 743082ec3b..191fecc75c 100644 --- a/src/renders/topRatedMovie.ts +++ b/src/renders/topRatedMovie.ts @@ -2,36 +2,42 @@ import { Movie } from "../services/dto"; export const renderTopRatedMovie = (movie: Movie) => { const topRatedContainer = document.querySelector(".top-rated-container"); - if (!topRatedContainer) return null; + if (!topRatedContainer) return; const overlay = document.querySelector(".overlay"); - if (!overlay) return null; - overlay.style.background = `url(${`https://media.themoviedb.org/t/p/w1920_and_h800_multi_faces` + movie.backdrop_path}) center center no-repeat`; + if (overlay) { + overlay.style.background = `url(${`https://media.themoviedb.org/t/p/w1920_and_h800_multi_faces` + movie.backdrop_path}) center center no-repeat`; + } const rateValue = topRatedContainer.querySelector(".rate-value"); - if (!rateValue) return null; - rateValue.textContent = movie.vote_average.toString(); + if (rateValue) { + rateValue.textContent = movie.vote_average.toString(); + } const title = topRatedContainer.querySelector(".title"); - if (!title) return null; - title.textContent = movie.title; + if (title) { + title.textContent = movie.title; + } }; export const removeTopRatedMovie = () => { const topRatedMovie = document.querySelector(".top-rated-movie"); - if (!topRatedMovie) return null; - topRatedMovie.style.display = "none"; + if (topRatedMovie) { + topRatedMovie.style.display = "none"; + } const background = document.querySelector( ".background-container", ); - if (!background) return null; - background.style.backgroundColor = "transparent"; - background.style.height = "auto"; + if (background) { + background.style.backgroundColor = "transparent"; + background.style.height = "auto"; + } const overlay = document.querySelector(".overlay"); - if (!overlay) return null; - overlay.style.background = ""; - overlay.style.display = "none"; + if (overlay) { + overlay.style.background = ""; + overlay.style.display = "none"; + } }; From 7454174d33909feed4b0e621585afc8a9e58a364 Mon Sep 17 00:00:00 2001 From: boyeon Date: Sun, 12 Apr 2026 00:42:37 +0900 Subject: [PATCH 51/77] =?UTF-8?q?refactor:=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cypress/e2e/error-handling.cy.ts | 3 ++- src/main.ts | 9 ++++----- .../movieLoad.ts => movieLoader.ts} | 17 +++++++---------- src/{controllers => }/search.ts | 6 +++--- src/{renders => view}/moreButton.ts | 0 src/{renders => view}/movieDetail.ts | 8 ++++---- src/{renders => view}/movieList.ts | 0 src/{renders => view}/movieModal.ts | 8 ++++---- src/{renders => view}/skeleton.ts | 0 src/{renders => view}/topRatedMovie.ts | 0 test/api.test.ts | 6 +++--- 11 files changed, 27 insertions(+), 30 deletions(-) rename src/{controllers/movieLoad.ts => movieLoader.ts} (84%) rename src/{controllers => }/search.ts (77%) rename src/{renders => view}/moreButton.ts (100%) rename src/{renders => view}/movieDetail.ts (94%) rename src/{renders => view}/movieList.ts (100%) rename src/{renders => view}/movieModal.ts (76%) rename src/{renders => view}/skeleton.ts (100%) rename src/{renders => view}/topRatedMovie.ts (100%) diff --git a/cypress/e2e/error-handling.cy.ts b/cypress/e2e/error-handling.cy.ts index 1283774a6c..f84b3994b3 100644 --- a/cypress/e2e/error-handling.cy.ts +++ b/cypress/e2e/error-handling.cy.ts @@ -36,7 +36,8 @@ describe("오류 대응 테스트", () => { const alertSpy = cy.stub(); cy.on("window:alert", alertSpy); - cy.get("#more-button").click(); + cy.get(".scroll-sentinel").scrollIntoView(); + cy.wait("@getInvalidPopularPage"); cy.wrap(alertSpy).should("have.been.called"); }); }); diff --git a/src/main.ts b/src/main.ts index 184440c5b5..d23ea86205 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,10 +1,9 @@ import { baseUrl } from "./constants/env"; -import { handleSearch } from "./controllers/search"; - -import { loadMovieList, loadTopRatedMovie } from "./controllers/movieLoad"; +import { loadMovieList, loadTopRatedMovie } from "./movieLoader"; +import { handleSearch } from "./search"; import { getDetailMovie } from "./services/api"; -import { clearMovieDetail, renderMovieDetail } from "./renders/movieDetail"; -import { closeMovieModal, openMovieModal } from "./renders/movieModal"; +import { clearMovieDetail, renderMovieDetail } from "./view/movieDetail"; +import { closeMovieModal, openMovieModal } from "./view/movieModal"; addEventListener("load", () => { const logo = document.querySelector(".logo"); diff --git a/src/controllers/movieLoad.ts b/src/movieLoader.ts similarity index 84% rename from src/controllers/movieLoad.ts rename to src/movieLoader.ts index 0266702897..7d131e1e92 100644 --- a/src/controllers/movieLoad.ts +++ b/src/movieLoader.ts @@ -2,22 +2,19 @@ import { getTopRatedMovies, getPopularMovies, getSearchMovies, -} from "../services/api"; +} from "./services/api"; -import { - renderTopRatedMovie, - removeTopRatedMovie, -} from "../renders/topRatedMovie"; +import { renderTopRatedMovie, removeTopRatedMovie } from "./view/topRatedMovie"; import { removeMovieList, renderMovieList, renderNoResult, -} from "../renders/movieList"; -import { renderSkeleton, removeSkeleton } from "../renders/skeleton"; +} from "./view/movieList"; +import { renderSkeleton, removeSkeleton } from "./view/skeleton"; -import PageState from "../states/PageState"; -import { getSearchParams, hasSearchParams } from "../utils/router"; -import { showError } from "../utils/error"; +import PageState from "./states/PageState"; +import { getSearchParams, hasSearchParams } from "./utils/router"; +import { showError } from "./utils/error"; const popularPageState = new PageState(); const searchPageState = new PageState(); diff --git a/src/controllers/search.ts b/src/search.ts similarity index 77% rename from src/controllers/search.ts rename to src/search.ts index 95630f8370..69194e4777 100644 --- a/src/controllers/search.ts +++ b/src/search.ts @@ -1,6 +1,6 @@ -import { baseUrl } from "../constants/env"; -import { navigate } from "../utils/router"; -import { loadSearchMovies } from "./movieLoad"; +import { baseUrl } from "./constants/env"; +import { loadSearchMovies } from "./movieLoader"; +import { navigate } from "./utils/router"; export const handleSearch = () => { const searchInput = document.querySelector("#search-input"); diff --git a/src/renders/moreButton.ts b/src/view/moreButton.ts similarity index 100% rename from src/renders/moreButton.ts rename to src/view/moreButton.ts diff --git a/src/renders/movieDetail.ts b/src/view/movieDetail.ts similarity index 94% rename from src/renders/movieDetail.ts rename to src/view/movieDetail.ts index 39abcf7664..1bb0a82354 100644 --- a/src/renders/movieDetail.ts +++ b/src/view/movieDetail.ts @@ -1,6 +1,6 @@ import { MovieDetail } from "../services/dto"; -export function renderMovieDetail(movieDetail: MovieDetail) { +export const renderMovieDetail = (movieDetail: MovieDetail) => { const movieModal = document.querySelector(".modal"); if (!movieModal) return null; @@ -35,9 +35,9 @@ export function renderMovieDetail(movieDetail: MovieDetail) { const stars = movieModal.querySelectorAll(".stars img"); stars[Number(key) / 2 - 1].click(); -} +}; -export function clearMovieDetail() { +export const clearMovieDetail = () => { const movieModal = document.querySelector(".modal"); if (!movieModal) return; @@ -54,4 +54,4 @@ export function clearMovieDetail() { const ratingValue = document.querySelector("#rating-value"); if (ratingValue) ratingValue.textContent = (0).toString(); -} +}; diff --git a/src/renders/movieList.ts b/src/view/movieList.ts similarity index 100% rename from src/renders/movieList.ts rename to src/view/movieList.ts diff --git a/src/renders/movieModal.ts b/src/view/movieModal.ts similarity index 76% rename from src/renders/movieModal.ts rename to src/view/movieModal.ts index c6221da639..1bc5345ac6 100644 --- a/src/renders/movieModal.ts +++ b/src/view/movieModal.ts @@ -1,13 +1,13 @@ -export function openMovieModal() { +export const openMovieModal = () => { const movieModal = document.querySelector("#modal-background"); if (!movieModal) return; movieModal.classList.add("active"); -} +}; -export function closeMovieModal() { +export const closeMovieModal = () => { const movieModal = document.querySelector("#modal-background"); if (!movieModal) return; movieModal.classList.remove("active"); -} +}; diff --git a/src/renders/skeleton.ts b/src/view/skeleton.ts similarity index 100% rename from src/renders/skeleton.ts rename to src/view/skeleton.ts diff --git a/src/renders/topRatedMovie.ts b/src/view/topRatedMovie.ts similarity index 100% rename from src/renders/topRatedMovie.ts rename to src/view/topRatedMovie.ts diff --git a/test/api.test.ts b/test/api.test.ts index e0711765ae..972fd63a14 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect, vi } from "vitest"; -import { getMoviePopular } from "../src/services/api"; +import { getPopularMovies } from "../src/services/api"; import { moviesFixture } from "./fixtures"; describe("api 함수 호출 테스트", () => { - it("getMoviePopular 를 호출하면 20개의 영화 목록을 가지고 온다", async () => { + it("getPopularMovies 를 호출하면 20개의 영화 목록을 가지고 온다", async () => { const mockData = { page: 1, results: [...moviesFixture], @@ -21,7 +21,7 @@ describe("api 함수 호출 테스트", () => { vi.stubGlobal("fetch", fetchMock); - const data = await getMoviePopular({ page: 1 }); + const data = await getPopularMovies({ page: 1 }); expect(data.results.length).toEqual(20); }); From 58a6fed8ceaf6585900095b5f5fdd897f7b21af0 Mon Sep 17 00:00:00 2001 From: boyeon Date: Sun, 12 Apr 2026 01:10:20 +0900 Subject: [PATCH 52/77] =?UTF-8?q?refactor:=20=EA=B2=80=EC=83=89=20url=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=9F=AC=20=ED=95=A8=EC=88=98=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.ts | 11 ++++++----- src/{search.ts => searchHandler.ts} | 12 ++++++------ src/utils/router.ts | 6 ++++++ 3 files changed, 18 insertions(+), 11 deletions(-) rename src/{search.ts => searchHandler.ts} (59%) diff --git a/src/main.ts b/src/main.ts index d23ea86205..1744396f9c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,9 @@ import { baseUrl } from "./constants/env"; import { loadMovieList, loadTopRatedMovie } from "./movieLoader"; -import { handleSearch } from "./search"; +import { + handleSearchButtonClick, + handleSearchInputEnter, +} from "./searchHandler"; import { getDetailMovie } from "./services/api"; import { clearMovieDetail, renderMovieDetail } from "./view/movieDetail"; import { closeMovieModal, openMovieModal } from "./view/movieModal"; @@ -12,14 +15,12 @@ addEventListener("load", () => { }); const searchButton = document.querySelector("#search-button"); - searchButton?.addEventListener("click", () => { - handleSearch(); - }); + searchButton?.addEventListener("click", handleSearchButtonClick); const searchInput = document.querySelector("#search-input"); searchInput?.addEventListener("keyup", (e: KeyboardEvent) => { if (e.key === "Enter") { - handleSearch(); + handleSearchInputEnter(); } }); diff --git a/src/search.ts b/src/searchHandler.ts similarity index 59% rename from src/search.ts rename to src/searchHandler.ts index 69194e4777..19766c4345 100644 --- a/src/search.ts +++ b/src/searchHandler.ts @@ -1,8 +1,8 @@ import { baseUrl } from "./constants/env"; import { loadSearchMovies } from "./movieLoader"; -import { navigate } from "./utils/router"; +import { createSearchUrl, navigate } from "./utils/router"; -export const handleSearch = () => { +export const handleSearchButtonClick = () => { const searchInput = document.querySelector("#search-input"); if (!searchInput) return; @@ -12,9 +12,9 @@ export const handleSearch = () => { return; } - const searchUrl = new URL(baseUrl, window.location.origin); - searchUrl.searchParams.set("search", search); - navigate(`${searchUrl.pathname}${searchUrl.search}`); - + const url = createSearchUrl(baseUrl, search); + navigate(url); loadSearchMovies({ reset: true }); }; + +export const handleSearchInputEnter = handleSearchButtonClick; diff --git a/src/utils/router.ts b/src/utils/router.ts index b44e6d391f..92b0f756ae 100644 --- a/src/utils/router.ts +++ b/src/utils/router.ts @@ -11,3 +11,9 @@ export const hasSearchParams = (queryKey: string): boolean => { const params = new URLSearchParams(location.search); return params.get(queryKey) === null ? false : true; }; + +export const createSearchUrl = (baseUrl: string, search: string) => { + const searchUrl = new URL(baseUrl, window.location.origin); + searchUrl.searchParams.set("search", search); + return `${searchUrl.pathname}${searchUrl.search}`; +}; From f8905d3c51eb49846564537a9d6f0059d4cc63f9 Mon Sep 17 00:00:00 2001 From: boyeon Date: Sun, 12 Apr 2026 02:02:00 +0900 Subject: [PATCH 53/77] =?UTF-8?q?refactor:=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=ED=96=89=ED=95=A8=EC=88=98=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main.ts | 29 ++++++++--------------------- src/modalHandler.ts | 22 ++++++++++++++++++++++ src/movieLoader.ts | 7 +++++++ src/services/api.ts | 2 +- 4 files changed, 38 insertions(+), 22 deletions(-) create mode 100644 src/modalHandler.ts diff --git a/src/main.ts b/src/main.ts index 1744396f9c..2025c85010 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,12 +1,14 @@ import { baseUrl } from "./constants/env"; +import { + handleModalCloseButtonClick, + handleModalEscapeKeydown, + handleMovieItemClick, +} from "./modalHandler"; import { loadMovieList, loadTopRatedMovie } from "./movieLoader"; import { handleSearchButtonClick, handleSearchInputEnter, } from "./searchHandler"; -import { getDetailMovie } from "./services/api"; -import { clearMovieDetail, renderMovieDetail } from "./view/movieDetail"; -import { closeMovieModal, openMovieModal } from "./view/movieModal"; addEventListener("load", () => { const logo = document.querySelector(".logo"); @@ -25,29 +27,14 @@ addEventListener("load", () => { }); const movieList = document.querySelector("#movie-list"); - movieList?.addEventListener("click", async (e) => { - const target = e.target as HTMLElement; - const movieItem = target.closest("li"); - if (!movieItem) return; - - const movieId = movieItem.dataset.movieId; - if (!movieId) return; - - const movieDetail = await getDetailMovie(movieId); - renderMovieDetail(movieDetail); - openMovieModal(); - }); + movieList?.addEventListener("click", handleMovieItemClick); const closeModal = document.querySelector("#close-modal"); - closeModal?.addEventListener("click", () => { - closeMovieModal(); - clearMovieDetail(); - }); + closeModal?.addEventListener("click", handleModalCloseButtonClick); document.addEventListener("keydown", (event) => { if (event.key === "Escape") { - closeMovieModal(); - clearMovieDetail(); + handleModalEscapeKeydown(); } }); diff --git a/src/modalHandler.ts b/src/modalHandler.ts new file mode 100644 index 0000000000..c4b6a0c369 --- /dev/null +++ b/src/modalHandler.ts @@ -0,0 +1,22 @@ +import { loadMovieDetail } from "./movieLoader"; +import { clearMovieDetail } from "./view/movieDetail"; +import { closeMovieModal, openMovieModal } from "./view/movieModal"; + +export const handleMovieItemClick = async (e: Event) => { + const target = e.target as HTMLElement; + const movieItem = target.closest("li"); + if (!movieItem) return; + + const movieId = movieItem.dataset.movieId; + if (!movieId) return; + + loadMovieDetail(movieId); + openMovieModal(); +}; + +export const handleModalCloseButtonClick = () => { + clearMovieDetail(); + closeMovieModal(); +}; + +export const handleModalEscapeKeydown = handleModalCloseButtonClick; diff --git a/src/movieLoader.ts b/src/movieLoader.ts index 7d131e1e92..644987e1c2 100644 --- a/src/movieLoader.ts +++ b/src/movieLoader.ts @@ -2,6 +2,7 @@ import { getTopRatedMovies, getPopularMovies, getSearchMovies, + getMovieDetail, } from "./services/api"; import { renderTopRatedMovie, removeTopRatedMovie } from "./view/topRatedMovie"; @@ -15,6 +16,7 @@ import { renderSkeleton, removeSkeleton } from "./view/skeleton"; import PageState from "./states/PageState"; import { getSearchParams, hasSearchParams } from "./utils/router"; import { showError } from "./utils/error"; +import { renderMovieDetail } from "./view/movieDetail"; const popularPageState = new PageState(); const searchPageState = new PageState(); @@ -98,3 +100,8 @@ export const loadMovieList = () => { loadPopularMovies(); }; + +export const loadMovieDetail = async (movieId: string) => { + const movieDetail = await getMovieDetail(movieId); + renderMovieDetail(movieDetail); +}; diff --git a/src/services/api.ts b/src/services/api.ts index 5f7cf91cb8..a2d9346e06 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -66,7 +66,7 @@ export const getSearchMovies = async ({ throw new ApiError(errorBody.status_message, errorBody.status_code); }; -export const getDetailMovie = async (movieId: string): Promise => { +export const getMovieDetail = async (movieId: string): Promise => { const url = `${apiUrl}/movie/${movieId}?language=ko-KR`; const res = await fetch(url, { method: "get", From ec3272efce45ca3e2877261117984e7a923351bd Mon Sep 17 00:00:00 2001 From: boyeon Date: Sun, 12 Apr 2026 02:17:41 +0900 Subject: [PATCH 54/77] =?UTF-8?q?refactor:=20=EB=B3=84=EC=A0=90=20UI=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=EC=99=80=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/constants/rating.ts | 9 +++++++++ src/main.ts | 31 ++----------------------------- src/ratingHandler.ts | 33 +++++++++++++++++++++++++++++++++ src/services/storage.ts | 3 +++ 4 files changed, 47 insertions(+), 29 deletions(-) create mode 100644 src/constants/rating.ts create mode 100644 src/ratingHandler.ts create mode 100644 src/services/storage.ts diff --git a/src/constants/rating.ts b/src/constants/rating.ts new file mode 100644 index 0000000000..32f106a631 --- /dev/null +++ b/src/constants/rating.ts @@ -0,0 +1,9 @@ +export const RATING_TEXTS = [ + "최악이에요", + "별로에요", + "보통이에요", + "재밌어요", + "명작이에요", +]; + +export const RATING_SCORES = ["2", "4", "6", "8", "10"]; diff --git a/src/main.ts b/src/main.ts index 2025c85010..078c977ecf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,6 +5,7 @@ import { handleMovieItemClick, } from "./modalHandler"; import { loadMovieList, loadTopRatedMovie } from "./movieLoader"; +import { handleRatingStarClick } from "./ratingHandler"; import { handleSearchButtonClick, handleSearchInputEnter, @@ -38,37 +39,9 @@ addEventListener("load", () => { } }); - const RATING_TEXT = [ - "최악이에요", - "별로에요", - "보통이에요", - "재밌어요", - "명작이에요", - ]; - const stars = document.querySelectorAll(".stars img"); stars.forEach((star, index) => { - star.addEventListener("click", () => { - stars.forEach((currentStar, currentIndex) => { - currentStar.src = - currentIndex <= index - ? "./images/star_filled.png" - : "./images/star_empty.png"; - }); - - const ratingText = document.querySelector(".rating-text"); - if (ratingText) ratingText.textContent = RATING_TEXT[index]; - - const ratingValue = document.querySelector("#rating-value"); - if (ratingValue) ratingValue.textContent = ((index + 1) * 2).toString(); - - const movieModal = document.querySelector(".modal"); - if (!movieModal) return; - const key = movieModal.dataset.movieId; - if (!key) return; - - window.localStorage.setItem(key, String((index + 1) * 2)); - }); + star.addEventListener("click", () => handleRatingStarClick(stars, index)); }); loadTopRatedMovie(); diff --git a/src/ratingHandler.ts b/src/ratingHandler.ts new file mode 100644 index 0000000000..62f6ca443a --- /dev/null +++ b/src/ratingHandler.ts @@ -0,0 +1,33 @@ +import { RATING_SCORES, RATING_TEXTS } from "./constants/rating"; +import { setLocalStorage } from "./services/storage"; + +export const handleRatingStarClick = ( + stars: NodeListOf, + index: number, +) => { + fillStars(stars, index); + updateRatingResult(index); + + const movieModal = document.querySelector(".modal"); + if (!movieModal) return; + const movieId = movieModal.dataset.movieId; + if (!movieId) return; + setLocalStorage(movieId, RATING_SCORES[index]); +}; + +const fillStars = (stars: NodeListOf, index: number) => { + stars.forEach((currentStar, currentIndex) => { + currentStar.src = + currentIndex <= index + ? "./images/star_filled.png" + : "./images/star_empty.png"; + }); +}; + +const updateRatingResult = (index: number) => { + const ratingText = document.querySelector(".rating-text"); + if (ratingText) ratingText.textContent = RATING_TEXTS[index]; + + const ratingValue = document.querySelector("#rating-value"); + if (ratingValue) ratingValue.textContent = RATING_SCORES[index]; +}; diff --git a/src/services/storage.ts b/src/services/storage.ts new file mode 100644 index 0000000000..9c63ccca84 --- /dev/null +++ b/src/services/storage.ts @@ -0,0 +1,3 @@ +export const setLocalStorage = (key: string, value: string) => { + window.localStorage.setItem(key, value); +}; From d0c0c3e132efe2082329734ddb2810252575396d Mon Sep 17 00:00:00 2001 From: boyeon Date: Sun, 12 Apr 2026 03:08:20 +0900 Subject: [PATCH 55/77] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=20=EB=B0=94=EC=9D=B8=EB=94=A9=EA=B3=BC=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=9F=AC=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/eventBinder.ts | 43 ++++++++++++++++++++++++++ src/{ => handlers}/modalHandler.ts | 6 ++-- src/handlers/ratingHandler.ts | 14 +++++++++ src/{ => handlers}/searchHandler.ts | 6 ++-- src/main.ts | 48 ++++++++--------------------- src/ratingHandler.ts | 33 -------------------- src/view/movieDetail.ts | 42 ++++++++++++++++++------- 7 files changed, 106 insertions(+), 86 deletions(-) create mode 100644 src/eventBinder.ts rename src/{ => handlers}/modalHandler.ts (72%) create mode 100644 src/handlers/ratingHandler.ts rename src/{ => handlers}/searchHandler.ts (73%) delete mode 100644 src/ratingHandler.ts diff --git a/src/eventBinder.ts b/src/eventBinder.ts new file mode 100644 index 0000000000..c7433140f8 --- /dev/null +++ b/src/eventBinder.ts @@ -0,0 +1,43 @@ +import { + handleModalCloseButtonClick, + handleModalEscapeKeydown, + handleMovieItemClick, +} from "./handlers/modalHandler"; +import { handleRatingStarClick } from "./handlers/ratingHandler"; +import { + handleSearchButtonClick, + handleSearchInputEnter, +} from "./handlers/searchHandler"; + +export const bindSearchEvents = () => { + const searchButton = document.querySelector("#search-button"); + searchButton?.addEventListener("click", handleSearchButtonClick); + + const searchInput = document.querySelector("#search-input"); + searchInput?.addEventListener("keyup", (e: KeyboardEvent) => { + if (e.key === "Enter") { + handleSearchInputEnter(); + } + }); +}; + +export const bindRatingEvents = () => { + const stars = document.querySelectorAll(".stars img"); + stars.forEach((star, index) => { + star.addEventListener("click", () => handleRatingStarClick(index)); + }); +}; + +export const bindModalEvents = () => { + const movieList = document.querySelector("#movie-list"); + movieList?.addEventListener("click", handleMovieItemClick); + + const closeModal = document.querySelector("#close-modal"); + closeModal?.addEventListener("click", handleModalCloseButtonClick); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + handleModalEscapeKeydown(); + } + }); +}; diff --git a/src/modalHandler.ts b/src/handlers/modalHandler.ts similarity index 72% rename from src/modalHandler.ts rename to src/handlers/modalHandler.ts index c4b6a0c369..79fb53c520 100644 --- a/src/modalHandler.ts +++ b/src/handlers/modalHandler.ts @@ -1,6 +1,6 @@ -import { loadMovieDetail } from "./movieLoader"; -import { clearMovieDetail } from "./view/movieDetail"; -import { closeMovieModal, openMovieModal } from "./view/movieModal"; +import { loadMovieDetail } from "../movieLoader"; +import { clearMovieDetail } from "../view/movieDetail"; +import { closeMovieModal, openMovieModal } from "../view/movieModal"; export const handleMovieItemClick = async (e: Event) => { const target = e.target as HTMLElement; diff --git a/src/handlers/ratingHandler.ts b/src/handlers/ratingHandler.ts new file mode 100644 index 0000000000..7c208dfc20 --- /dev/null +++ b/src/handlers/ratingHandler.ts @@ -0,0 +1,14 @@ +import { RATING_SCORES } from "../constants/rating"; +import { setLocalStorage } from "../services/storage"; +import { fillStars, updateRatingResult } from "../view/movieDetail"; + +export const handleRatingStarClick = (index: number) => { + fillStars(index); + updateRatingResult(index); + + const movieModal = document.querySelector(".modal"); + if (!movieModal) return; + const movieId = movieModal.dataset.movieId; + if (!movieId) return; + setLocalStorage(movieId, RATING_SCORES[index]); +}; diff --git a/src/searchHandler.ts b/src/handlers/searchHandler.ts similarity index 73% rename from src/searchHandler.ts rename to src/handlers/searchHandler.ts index 19766c4345..8d1eb63ba3 100644 --- a/src/searchHandler.ts +++ b/src/handlers/searchHandler.ts @@ -1,6 +1,6 @@ -import { baseUrl } from "./constants/env"; -import { loadSearchMovies } from "./movieLoader"; -import { createSearchUrl, navigate } from "./utils/router"; +import { baseUrl } from "../constants/env"; +import { loadSearchMovies } from "../movieLoader"; +import { createSearchUrl, navigate } from "../utils/router"; export const handleSearchButtonClick = () => { const searchInput = document.querySelector("#search-input"); diff --git a/src/main.ts b/src/main.ts index 078c977ecf..a29370cbd5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,15 +1,10 @@ import { baseUrl } from "./constants/env"; import { - handleModalCloseButtonClick, - handleModalEscapeKeydown, - handleMovieItemClick, -} from "./modalHandler"; + bindModalEvents, + bindRatingEvents, + bindSearchEvents, +} from "./eventBinder"; import { loadMovieList, loadTopRatedMovie } from "./movieLoader"; -import { handleRatingStarClick } from "./ratingHandler"; -import { - handleSearchButtonClick, - handleSearchInputEnter, -} from "./searchHandler"; addEventListener("load", () => { const logo = document.querySelector(".logo"); @@ -17,36 +12,17 @@ addEventListener("load", () => { window.location.href = baseUrl; }); - const searchButton = document.querySelector("#search-button"); - searchButton?.addEventListener("click", handleSearchButtonClick); - - const searchInput = document.querySelector("#search-input"); - searchInput?.addEventListener("keyup", (e: KeyboardEvent) => { - if (e.key === "Enter") { - handleSearchInputEnter(); - } - }); - - const movieList = document.querySelector("#movie-list"); - movieList?.addEventListener("click", handleMovieItemClick); - - const closeModal = document.querySelector("#close-modal"); - closeModal?.addEventListener("click", handleModalCloseButtonClick); - - document.addEventListener("keydown", (event) => { - if (event.key === "Escape") { - handleModalEscapeKeydown(); - } - }); - - const stars = document.querySelectorAll(".stars img"); - stars.forEach((star, index) => { - star.addEventListener("click", () => handleRatingStarClick(stars, index)); - }); + bindSearchEvents(); + bindModalEvents(); + bindRatingEvents(); loadTopRatedMovie(); loadMovieList(); + initializeMovieListObserver(); +}); + +const initializeMovieListObserver = () => { const movieListObserver = new IntersectionObserver( (entries) => { const [sentinelEntry] = entries; @@ -59,4 +35,4 @@ addEventListener("load", () => { const sentinel = document.querySelector(".scroll-sentinel"); if (sentinel) movieListObserver.observe(sentinel); -}); +}; diff --git a/src/ratingHandler.ts b/src/ratingHandler.ts deleted file mode 100644 index 62f6ca443a..0000000000 --- a/src/ratingHandler.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { RATING_SCORES, RATING_TEXTS } from "./constants/rating"; -import { setLocalStorage } from "./services/storage"; - -export const handleRatingStarClick = ( - stars: NodeListOf, - index: number, -) => { - fillStars(stars, index); - updateRatingResult(index); - - const movieModal = document.querySelector(".modal"); - if (!movieModal) return; - const movieId = movieModal.dataset.movieId; - if (!movieId) return; - setLocalStorage(movieId, RATING_SCORES[index]); -}; - -const fillStars = (stars: NodeListOf, index: number) => { - stars.forEach((currentStar, currentIndex) => { - currentStar.src = - currentIndex <= index - ? "./images/star_filled.png" - : "./images/star_empty.png"; - }); -}; - -const updateRatingResult = (index: number) => { - const ratingText = document.querySelector(".rating-text"); - if (ratingText) ratingText.textContent = RATING_TEXTS[index]; - - const ratingValue = document.querySelector("#rating-value"); - if (ratingValue) ratingValue.textContent = RATING_SCORES[index]; -}; diff --git a/src/view/movieDetail.ts b/src/view/movieDetail.ts index 1bb0a82354..b540c07e8e 100644 --- a/src/view/movieDetail.ts +++ b/src/view/movieDetail.ts @@ -1,8 +1,9 @@ +import { RATING_SCORES, RATING_TEXTS } from "../constants/rating"; import { MovieDetail } from "../services/dto"; export const renderMovieDetail = (movieDetail: MovieDetail) => { const movieModal = document.querySelector(".modal"); - if (!movieModal) return null; + if (!movieModal) return; movieModal.dataset.movieId = String(movieDetail.id); @@ -13,12 +14,11 @@ export const renderMovieDetail = (movieDetail: MovieDetail) => { const rate = movieModal.querySelector(".rate-value"); const detail = movieModal.querySelector(".detail"); - if (modalImage) + if (modalImage) { modalImage.src = `https://media.themoviedb.org/t/p/w300_and_h450_face` + movieDetail.poster_path; - - if (title) title.textContent = movieDetail.title; + } if (category) { const releaseYear = new Date(movieDetail.release_date).getFullYear(); @@ -26,15 +26,16 @@ export const renderMovieDetail = (movieDetail: MovieDetail) => { category.textContent = `${releaseYear} · ${genres}`; } + if (title) title.textContent = movieDetail.title; if (rate) rate.textContent = movieDetail.vote_average.toString(); - if (detail) detail.textContent = movieDetail.overview; - const key = window.localStorage.getItem(String(movieDetail.id)); - if (!key) return; + const ratingScore = window.localStorage.getItem(String(movieDetail.id)); + if (!ratingScore) return; - const stars = movieModal.querySelectorAll(".stars img"); - stars[Number(key) / 2 - 1].click(); + const index = RATING_SCORES.indexOf(ratingScore); + fillStars(index); + updateRatingResult(index); }; export const clearMovieDetail = () => { @@ -49,9 +50,28 @@ export const clearMovieDetail = () => { const stars = movieModal.querySelectorAll(".stars img"); stars.forEach((star) => (star.src = "./images/star_empty.png")); - const ratingText = document.querySelector(".rating-text"); + const ratingText = movieModal.querySelector(".rating-text"); if (ratingText) ratingText.textContent = "평가해주세요"; + const ratingValue = movieModal.querySelector("#rating-value"); + if (ratingValue) ratingValue.textContent = "0"; +}; + +export const fillStars = (index: number) => { + const stars = document.querySelectorAll(".stars img"); + + stars.forEach((currentStar, currentIndex) => { + currentStar.src = + currentIndex <= index + ? "./images/star_filled.png" + : "./images/star_empty.png"; + }); +}; + +export const updateRatingResult = (index: number) => { + const ratingText = document.querySelector(".rating-text"); + if (ratingText) ratingText.textContent = RATING_TEXTS[index]; + const ratingValue = document.querySelector("#rating-value"); - if (ratingValue) ratingValue.textContent = (0).toString(); + if (ratingValue) ratingValue.textContent = RATING_SCORES[index]; }; From 37c7ac3135c36ce2e9585b3015db56673ef13cd8 Mon Sep 17 00:00:00 2001 From: boyeon Date: Sun, 12 Apr 2026 11:18:51 +0900 Subject: [PATCH 56/77] =?UTF-8?q?feat:=20=EB=AA=A8=EB=8B=AC=20=EC=97=B4?= =?UTF-8?q?=EB=A6=BC=20=EC=8B=9C=20=EB=B0=B0=EA=B2=BD=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A1=A4=20=EB=A7=89=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/view/movieModal.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/view/movieModal.ts b/src/view/movieModal.ts index 1bc5345ac6..2a3881cdd6 100644 --- a/src/view/movieModal.ts +++ b/src/view/movieModal.ts @@ -3,6 +3,7 @@ export const openMovieModal = () => { if (!movieModal) return; movieModal.classList.add("active"); + document.body.classList.add("modal-open"); }; export const closeMovieModal = () => { @@ -10,4 +11,5 @@ export const closeMovieModal = () => { if (!movieModal) return; movieModal.classList.remove("active"); + document.body.classList.remove("modal-open"); }; From b7c6219225b024305875bcd874352ea0acbc088a Mon Sep 17 00:00:00 2001 From: boyeon Date: Sun, 12 Apr 2026 11:49:19 +0900 Subject: [PATCH 57/77] =?UTF-8?q?style:=20=ED=83=9C=EB=B8=94=EB=A6=BF=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20=EB=B0=98=EC=9D=91?= =?UTF-8?q?=ED=98=95=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- public/styles/main.css | 15 +++++++++++ public/styles/modal.css | 52 ++++++++++++++++++++++++++++++++++++- public/styles/search.css | 21 ++++++++++++++- public/styles/thumbnail.css | 10 +++++++ 5 files changed, 97 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6fc5c7fa81..8d27d448d1 100644 --- a/README.md +++ b/README.md @@ -48,5 +48,5 @@ FE 레벨1 영화 리뷰 미션입니다. ### UI⁄UX 개선하기 - [x] 브라우저 화면의 끝에 도달하면 영화 20개를 자동으로 로드한다. (무한 스크롤) -- [ ] 태블릿 화면에서 영화 목록과 모달 레이아웃이 반응형으로 동작하도록 한다. +- [x] 태블릿 화면에서 영화 목록과 모달 레이아웃이 반응형으로 동작하도록 한다. - [ ] 모바일 화면에서 영화 목록과 모달 레이아웃이 반응형으로 동작하도록 한다. diff --git a/public/styles/main.css b/public/styles/main.css index 0a62f5d423..322d045ecd 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -141,3 +141,18 @@ footer.footer { footer.footer p:not(:last-child) { margin-bottom: 8px; } + +/* 테블릿 */ +@media (max-width: 1023px) { + #wrap { + min-width: 800px; + padding: 0 16px; + } + + #wrap, + section { + display: flex; + flex-direction: column; + justify-content: center; + } +} diff --git a/public/styles/modal.css b/public/styles/modal.css index 52c4079ef8..ab64244e1e 100644 --- a/public/styles/modal.css +++ b/public/styles/modal.css @@ -37,7 +37,6 @@ body.modal-open { z-index: 2; position: relative; width: 1000px; - height: 613px; } .close-modal { @@ -130,3 +129,54 @@ body.modal-open { max-height: 300px; overflow-y: auto; } + +/* 테블릿 */ +@media (max-width: 1023px) { + .modal-background { + align-items: flex-end; + } + + .modal { + max-height: 950px; + border-radius: 16px 16px 0 0; + } + + .modal-image img { + width: 200px; + margin: 18px; + } + + .modal-container { + flex-direction: column; + align-items: center; + } + + .modal-description { + margin: 0 14px; + } + + .modal-description h2 { + text-align: center; + padding-bottom: 16px; + } + + .modal-description .category { + text-align: center; + } + + .modal-description .rate { + justify-content: center; + } + + .modal-description h3 { + padding: 15px 0; + } + + hr { + margin: 13px 0; + } + + .detail { + max-height: 155px; + } +} diff --git a/public/styles/search.css b/public/styles/search.css index f58d097596..eb72ebfa63 100644 --- a/public/styles/search.css +++ b/public/styles/search.css @@ -1,11 +1,11 @@ .header-bar { position: relative; max-width: 1280px; - width: 1280px; margin: 0 auto; text-align: center; z-index: 10; } + .header-bar .logo { position: absolute; top: 0; @@ -51,3 +51,22 @@ height: 32px; background: url("../images/search.png") center center no-repeat; } + +/* 테블릿 */ +@media (max-width: 1023px) { + .header-bar { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + } + + .header-bar .logo { + position: static; + } + + .search-box { + width: 100%; + max-width: 525px; + } +} diff --git a/public/styles/thumbnail.css b/public/styles/thumbnail.css index 378a4942c9..f655ae67cf 100644 --- a/public/styles/thumbnail.css +++ b/public/styles/thumbnail.css @@ -38,3 +38,13 @@ p.rate > span { width: 16px; top: 1px; } + +/* 테블릿 */ +@media (max-width: 1023px) { + .thumbnail-list { + margin: 0 auto 56px; + display: grid; + grid-template-columns: repeat(3, 200px); + gap: 70px; + } +} From 3bc2492eb9f89ab9231f637368024643593e4332 Mon Sep 17 00:00:00 2001 From: boyeon Date: Sun, 12 Apr 2026 14:36:33 +0900 Subject: [PATCH 58/77] =?UTF-8?q?style:=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20=EB=B0=98=EC=9D=91?= =?UTF-8?q?=ED=98=95=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/styles/main.css | 39 +++++++++++++++++++++++++++++++++++- public/styles/modal.css | 40 ++++++++++++++++++++++++++++++------- public/styles/search.css | 18 +++++++++++++++++ public/styles/thumbnail.css | 20 ++++++++++++++++--- 4 files changed, 106 insertions(+), 11 deletions(-) diff --git a/public/styles/main.css b/public/styles/main.css index 322d045ecd..6faa4f15e9 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -61,7 +61,6 @@ button.primary { } .container { - max-width: 1280px; margin: 0 auto; } @@ -70,6 +69,7 @@ button.primary { background-position: center center; background-size: cover; height: 500px; + min-width: 1440px; padding: 48px; } @@ -80,6 +80,9 @@ button.primary { background-color: rgba(0, 0, 0, 0.5); width: 100%; height: 100%; + background-size: cover; + background-position: center center; + background-repeat: no-repeat; } .top-rated-container { @@ -144,6 +147,10 @@ footer.footer p:not(:last-child) { /* 테블릿 */ @media (max-width: 1023px) { + .background-container { + min-width: 800px; + } + #wrap { min-width: 800px; padding: 0 16px; @@ -156,3 +163,33 @@ footer.footer p:not(:last-child) { justify-content: center; } } + +/* 테블릿 */ +@media (max-width: 1023px) { + .background-container, + #wrap { + min-width: 0; + } + + #wrap { + padding: 0 16px; + } + + .background-container { + height: 420px; + padding: 32px 16px; + } +} + +/* 모바일 */ +@media (max-width: 767px) { + .background-container, + #wrap { + min-width: 310px; + } + + .background-container { + height: 320px; + padding: 24px 16px; + } +} diff --git a/public/styles/modal.css b/public/styles/modal.css index ab64244e1e..d2f23fb60c 100644 --- a/public/styles/modal.css +++ b/public/styles/modal.css @@ -66,6 +66,7 @@ body.modal-open { padding: 8px; margin-left: 16px; line-height: 1.6rem; + font-size: 1.2rem; } .modal-description .rate > img { @@ -79,7 +80,7 @@ body.modal-open { } .modal-description .rate-value { - font-size: 16px; + font-size: 1.2rem; font-weight: normal; } @@ -116,7 +117,7 @@ body.modal-open { .modal-description h2 { font-size: 2rem; font-weight: bold; - margin: 0 0 8px; + margin: 0 0 4px 0; } .modal-description h3 { @@ -137,8 +138,9 @@ body.modal-open { } .modal { - max-height: 950px; + max-height: 750px; border-radius: 16px 16px 0 0; + overflow-y: scroll; } .modal-image img { @@ -169,14 +171,38 @@ body.modal-open { } .modal-description h3 { - padding: 15px 0; + padding: 10px 0 5px 0; } - hr { - margin: 13px 0; + .detail { + max-height: none; + overflow-y: visible; + } +} + +/* 모바일 */ +@media (max-width: 767px) { + .modal-image { + display: none; + } + + .modal-description { + text-align: center; + } + + .movie-rating { + flex-wrap: wrap; + justify-content: center; + max-width: none; + gap: 8px; + } + + .movie-rating .stars { + justify-content: center; + width: 100%; } .detail { - max-height: 155px; + text-align: left; } } diff --git a/public/styles/search.css b/public/styles/search.css index eb72ebfa63..7dfd687608 100644 --- a/public/styles/search.css +++ b/public/styles/search.css @@ -70,3 +70,21 @@ max-width: 525px; } } + +/* 모바일 */ +@media (max-width: 767px) { + .search-box { + width: 100%; + max-width: 280px; + position: relative; + } + + #search-input { + width: 100%; + padding-right: 44px; + } + + .search-box button { + right: 8px; + } +} diff --git a/public/styles/thumbnail.css b/public/styles/thumbnail.css index f655ae67cf..5eb808b40a 100644 --- a/public/styles/thumbnail.css +++ b/public/styles/thumbnail.css @@ -18,6 +18,16 @@ cursor: pointer; } +.item-desc { + margin: 5px 0; + font-weight: 500; +} + +.item-desc strong { + margin: 5px 0; + font-size: 1.1rem; +} + .item-desc > *:not(:last-child) { position: relative; margin-bottom: 4px; @@ -42,9 +52,13 @@ p.rate > span { /* 테블릿 */ @media (max-width: 1023px) { .thumbnail-list { - margin: 0 auto 56px; - display: grid; grid-template-columns: repeat(3, 200px); - gap: 70px; + } +} + +/* 모바일 */ +@media (max-width: 767px) { + .thumbnail-list { + grid-template-columns: repeat(1, 200px); } } From 49be0e82e902da4fc45945a4ef5fe97bd92ca0ca Mon Sep 17 00:00:00 2001 From: boyeon Date: Sun, 12 Apr 2026 14:45:07 +0900 Subject: [PATCH 59/77] =?UTF-8?q?feat:=20=EB=AA=A8=EB=8B=AC=20=EB=B0=94?= =?UTF-8?q?=EA=B9=A5=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20=EB=8B=AB=EA=B8=B0?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/eventBinder.ts | 11 +++++++++++ src/handlers/modalHandler.ts | 2 ++ 2 files changed, 13 insertions(+) diff --git a/src/eventBinder.ts b/src/eventBinder.ts index c7433140f8..085d60c230 100644 --- a/src/eventBinder.ts +++ b/src/eventBinder.ts @@ -1,4 +1,5 @@ import { + handleModalBackdropClick, handleModalCloseButtonClick, handleModalEscapeKeydown, handleMovieItemClick, @@ -35,6 +36,16 @@ export const bindModalEvents = () => { const closeModal = document.querySelector("#close-modal"); closeModal?.addEventListener("click", handleModalCloseButtonClick); + const modalBackground = document.querySelector("#modal-background"); + modalBackground?.addEventListener("click", () => { + handleModalBackdropClick(); + }); + + const modal = document.querySelector(".modal"); + modal?.addEventListener("click", (event) => { + event.stopPropagation(); + }); + document.addEventListener("keydown", (event) => { if (event.key === "Escape") { handleModalEscapeKeydown(); diff --git a/src/handlers/modalHandler.ts b/src/handlers/modalHandler.ts index 79fb53c520..5c40835f50 100644 --- a/src/handlers/modalHandler.ts +++ b/src/handlers/modalHandler.ts @@ -20,3 +20,5 @@ export const handleModalCloseButtonClick = () => { }; export const handleModalEscapeKeydown = handleModalCloseButtonClick; + +export const handleModalBackdropClick = handleModalCloseButtonClick; From 5c5905e63994f6196a268d35c0b118c0f113459c Mon Sep 17 00:00:00 2001 From: boyeon Date: Sun, 12 Apr 2026 17:15:29 +0900 Subject: [PATCH 60/77] =?UTF-8?q?fix:=20=ED=99=94=EB=A9=B4=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=EC=97=90=20=EB=94=B0=EB=9D=BC=20=EC=83=81=EB=8B=A8=20?= =?UTF-8?q?=EB=B0=B0=EA=B2=BD=20=EC=9D=B4=EB=AF=B8=EC=A7=80=EA=B0=80=20?= =?UTF-8?q?=EB=B9=84=EC=9C=A8=EB=8C=80=EB=A1=9C=20=EC=B6=95=EC=86=8C?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- public/styles/modal.css | 5 ++++- src/view/topRatedMovie.ts | 10 ++++------ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8d27d448d1..a51dbcf55a 100644 --- a/README.md +++ b/README.md @@ -49,4 +49,4 @@ FE 레벨1 영화 리뷰 미션입니다. - [x] 브라우저 화면의 끝에 도달하면 영화 20개를 자동으로 로드한다. (무한 스크롤) - [x] 태블릿 화면에서 영화 목록과 모달 레이아웃이 반응형으로 동작하도록 한다. -- [ ] 모바일 화면에서 영화 목록과 모달 레이아웃이 반응형으로 동작하도록 한다. +- [x] 모바일 화면에서 영화 목록과 모달 레이아웃이 반응형으로 동작하도록 한다. diff --git a/public/styles/modal.css b/public/styles/modal.css index d2f23fb60c..61c72d9d93 100644 --- a/public/styles/modal.css +++ b/public/styles/modal.css @@ -66,7 +66,6 @@ body.modal-open { padding: 8px; margin-left: 16px; line-height: 1.6rem; - font-size: 1.2rem; } .modal-description .rate > img { @@ -182,6 +181,10 @@ body.modal-open { /* 모바일 */ @media (max-width: 767px) { + .modal { + max-height: 600px; + } + .modal-image { display: none; } diff --git a/src/view/topRatedMovie.ts b/src/view/topRatedMovie.ts index 191fecc75c..60744a6b03 100644 --- a/src/view/topRatedMovie.ts +++ b/src/view/topRatedMovie.ts @@ -1,13 +1,11 @@ import { Movie } from "../services/dto"; export const renderTopRatedMovie = (movie: Movie) => { - const topRatedContainer = document.querySelector(".top-rated-container"); + const topRatedContainer = document.querySelector( + ".background-container", + ); if (!topRatedContainer) return; - - const overlay = document.querySelector(".overlay"); - if (overlay) { - overlay.style.background = `url(${`https://media.themoviedb.org/t/p/w1920_and_h800_multi_faces` + movie.backdrop_path}) center center no-repeat`; - } + topRatedContainer.style.backgroundImage = `url(${`https://media.themoviedb.org/t/p/w1920_and_h800_multi_faces` + movie.backdrop_path})`; const rateValue = topRatedContainer.querySelector(".rate-value"); if (rateValue) { From c5715c66f23171b8c22d9c75ccd233d80a7f25ea Mon Sep 17 00:00:00 2001 From: boyeon Date: Sun, 12 Apr 2026 18:08:29 +0900 Subject: [PATCH 61/77] =?UTF-8?q?fix:=20=EA=B2=80=EC=83=89=20=EC=8B=9C=20?= =?UTF-8?q?=EC=83=81=EB=8B=A8=20=EB=B0=B0=EA=B2=BD=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=EC=99=80=20=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/view/topRatedMovie.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/view/topRatedMovie.ts b/src/view/topRatedMovie.ts index 60744a6b03..21c620bcfa 100644 --- a/src/view/topRatedMovie.ts +++ b/src/view/topRatedMovie.ts @@ -25,17 +25,16 @@ export const removeTopRatedMovie = () => { topRatedMovie.style.display = "none"; } - const background = document.querySelector( + const topRatedContainer = document.querySelector( ".background-container", ); - if (background) { - background.style.backgroundColor = "transparent"; - background.style.height = "auto"; + if (topRatedContainer) { + topRatedContainer.style.backgroundImage = ""; + topRatedContainer.style.height = "auto"; } const overlay = document.querySelector(".overlay"); if (overlay) { - overlay.style.background = ""; overlay.style.display = "none"; } }; From 047f86ae166806ebb93eb2c877bb810c40197527 Mon Sep 17 00:00:00 2001 From: boyeon Date: Sun, 12 Apr 2026 20:56:58 +0900 Subject: [PATCH 62/77] =?UTF-8?q?test:=20=EB=AA=A8=EB=8B=AC=20=EC=97=B4?= =?UTF-8?q?=EB=A6=BC=20=EB=8B=AB=ED=9E=98=20=EA=B8=B0=EB=8A=A5=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cypress/e2e/movie-modal.cy.ts | 56 +++++++++++++++++++++++++++++++++++ public/styles/main.css | 1 + test/fixtures.ts | 24 +++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 cypress/e2e/movie-modal.cy.ts diff --git a/cypress/e2e/movie-modal.cy.ts b/cypress/e2e/movie-modal.cy.ts new file mode 100644 index 0000000000..f895811303 --- /dev/null +++ b/cypress/e2e/movie-modal.cy.ts @@ -0,0 +1,56 @@ +import { movieDetailFixture, moviesFixture } from "../../test/fixtures"; + +describe("영화 모달 기능 테스트", () => { + const openMovieModal = () => { + cy.get("#movie-list li").first().click(); + cy.wait("@getMovieDetail"); + cy.get("#modal-background").should("have.class", "active"); + }; + + beforeEach(() => { + cy.intercept("GET", "**/movie/popular?page=1&language=ko-KR", { + statusCode: 200, + body: { + page: 1, + results: [...moviesFixture], + total_pages: 2, + total_results: 40, + }, + }).as("getPopularPage1"); + + cy.intercept("GET", "**/movie/640146?language=ko-KR", { + statusCode: 200, + body: movieDetailFixture, + }).as("getMovieDetail"); + + cy.visit("localhost:5173"); + cy.wait("@getPopularPage1"); + }); + + it("영화 목록을 클릭하면 모달이 뜨고 닫기 버튼으로 닫을 수 있다", () => { + openMovieModal(); + + cy.get("#close-modal").click(); + cy.get("#modal-background").should("not.have.class", "active"); + }); + + it("ESC 버튼을 누르면 모달이 닫힌다", () => { + openMovieModal(); + + cy.get("body").type("{esc}"); + cy.get("#modal-background").should("not.have.class", "active"); + }); + + it("모달창이 아닌 부분을 클릭하면 모달이 닫힌다", () => { + openMovieModal(); + + cy.get("#modal-background").click("topLeft"); + cy.get("#modal-background").should("not.have.class", "active"); + }); + + it("모달창이 뜨면 배경 스크롤이 적용되지 않는다", () => { + openMovieModal(); + + cy.get("body").should("have.class", "modal-open"); + }); +}); diff --git a/public/styles/main.css b/public/styles/main.css index 6faa4f15e9..cda95f51d0 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -183,6 +183,7 @@ footer.footer p:not(:last-child) { /* 모바일 */ @media (max-width: 767px) { + header, .background-container, #wrap { min-width: 310px; diff --git a/test/fixtures.ts b/test/fixtures.ts index 8a4fef67ec..b55a21cb38 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -683,3 +683,27 @@ export const searchFixture = [ vote_count: 79, }, ]; + +export const movieDetailFixture = { + id: 640146, + title: "앤트맨과 와스프: 퀀텀매니아", + poster_path: "/ngl2FKBlU4fhbdsrtdom9LVLBXw.jpg", + overview: + "슈퍼히어로 파트너인 스캇 랭과 호프 반 다인, 호프의 부모 재닛 반 다인과 행크 핌, 그리고 스캇의 딸 캐시 랭까지 미지의 양자 영역 세계 속에 빠져버린 앤트맨 패밀리. 그 곳에서 새로운 존재들과 무한한 우주를 다스리는 정복자 캉을 만나며, 그 누구도 예상 못 한 모든 것의 한계를 뛰어넘는 모험을 시작하게 되는데…", + release_date: "2023-02-15", + vote_average: 6.232, + genres: [ + { + id: 28, + name: "액션", + }, + { + id: 12, + name: "모험", + }, + { + id: 878, + name: "SF", + }, + ], +}; From deb2ec4518bac58af78295558c480bf0b6c0a217 Mon Sep 17 00:00:00 2001 From: boyeon Date: Sun, 12 Apr 2026 21:24:48 +0900 Subject: [PATCH 63/77] =?UTF-8?q?test:=20=EB=B3=84=EC=A0=90=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EB=B0=8F=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cypress/e2e/movie-rating.cy.ts | 77 ++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 cypress/e2e/movie-rating.cy.ts diff --git a/cypress/e2e/movie-rating.cy.ts b/cypress/e2e/movie-rating.cy.ts new file mode 100644 index 0000000000..a7119a3643 --- /dev/null +++ b/cypress/e2e/movie-rating.cy.ts @@ -0,0 +1,77 @@ +import { movieDetailFixture, moviesFixture } from "../../test/fixtures"; +import { RATING_SCORES, RATING_TEXTS } from "../../src/constants/rating"; + +describe("영화 별점 기능 테스트", () => { + const openMovieModal = () => { + cy.get("#movie-list li").first().click(); + cy.wait("@getMovieDetail"); + cy.get("#modal-background").should("have.class", "active"); + }; + + beforeEach(() => { + cy.intercept("GET", "**/movie/popular?page=1&language=ko-KR", { + statusCode: 200, + body: { + page: 1, + results: [...moviesFixture], + total_pages: 2, + total_results: 40, + }, + }).as("getPopularPage1"); + + cy.intercept("GET", "**/movie/640146?language=ko-KR", { + statusCode: 200, + body: movieDetailFixture, + }).as("getMovieDetail"); + + cy.visit("localhost:5173"); + cy.wait("@getPopularPage1"); + cy.clearLocalStorage(); + }); + + it("모달창에서 별점을 클릭하면 해당 별만큼 채워지고 점수와 평가 문구가 바뀐다", () => { + openMovieModal(); + + cy.get(".movie-rating .stars img").eq(3).click(); + + cy.get(".movie-rating .stars img") + .eq(0) + .should("have.attr", "src") + .and("include", "star_filled.png"); + + cy.get(".movie-rating .stars img") + .eq(3) + .should("have.attr", "src") + .and("include", "star_filled.png"); + + cy.get(".movie-rating .stars img") + .eq(4) + .should("have.attr", "src") + .and("include", "star_empty.png"); + + cy.get(".rating-text").should("have.text", RATING_TEXTS[3]); + cy.get("#rating-value").should("have.text", RATING_SCORES[3]); + }); + + it("별점을 매기고 다시 모달을 열면 이전 별점이 유지된다", () => { + openMovieModal(); + + cy.get(".movie-rating .stars img").eq(3).click(); + cy.get("#close-modal").click(); + + openMovieModal(); + + cy.get(".rating-text").should("have.text", RATING_TEXTS[3]); + cy.get("#rating-value").should("have.text", RATING_SCORES[3]); + + cy.get(".movie-rating .stars img") + .eq(3) + .should("have.attr", "src") + .and("include", "star_filled.png"); + + cy.get(".movie-rating .stars img") + .eq(4) + .should("have.attr", "src") + .and("include", "star_empty.png"); + }); +}); From e37cfa34507f4ab7f9d2cdf9544041c2535d19ea Mon Sep 17 00:00:00 2001 From: boyeon Date: Mon, 13 Apr 2026 14:42:41 +0900 Subject: [PATCH 64/77] =?UTF-8?q?fix:=20=EC=98=81=ED=99=94=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EB=AA=A8=EB=8B=AC=20API=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/movieLoader.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/movieLoader.ts b/src/movieLoader.ts index 644987e1c2..ab78c7c344 100644 --- a/src/movieLoader.ts +++ b/src/movieLoader.ts @@ -102,6 +102,10 @@ export const loadMovieList = () => { }; export const loadMovieDetail = async (movieId: string) => { - const movieDetail = await getMovieDetail(movieId); - renderMovieDetail(movieDetail); + try { + const movieDetail = await getMovieDetail(movieId); + renderMovieDetail(movieDetail); + } catch (e) { + showError(e); + } }; From 5d212b939867db8f9fc1855394fe89a73f1ea353 Mon Sep 17 00:00:00 2001 From: boyeon Date: Mon, 13 Apr 2026 14:56:58 +0900 Subject: [PATCH 65/77] =?UTF-8?q?refactor:=20localStorage=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/storage.ts | 4 ++++ src/view/movieDetail.ts | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/services/storage.ts b/src/services/storage.ts index 9c63ccca84..d394887f3d 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -1,3 +1,7 @@ export const setLocalStorage = (key: string, value: string) => { window.localStorage.setItem(key, value); }; + +export const getLocalStorage = (key: string): string | null => { + return window.localStorage.getItem(key); +}; diff --git a/src/view/movieDetail.ts b/src/view/movieDetail.ts index b540c07e8e..3d24e90810 100644 --- a/src/view/movieDetail.ts +++ b/src/view/movieDetail.ts @@ -1,5 +1,6 @@ import { RATING_SCORES, RATING_TEXTS } from "../constants/rating"; import { MovieDetail } from "../services/dto"; +import { getLocalStorage } from "../services/storage"; export const renderMovieDetail = (movieDetail: MovieDetail) => { const movieModal = document.querySelector(".modal"); @@ -30,7 +31,7 @@ export const renderMovieDetail = (movieDetail: MovieDetail) => { if (rate) rate.textContent = movieDetail.vote_average.toString(); if (detail) detail.textContent = movieDetail.overview; - const ratingScore = window.localStorage.getItem(String(movieDetail.id)); + const ratingScore = getLocalStorage(String(movieDetail.id)); if (!ratingScore) return; const index = RATING_SCORES.indexOf(ratingScore); From d92fe72d433b6c4ef9f7a9b27d00690aa5e27cf4 Mon Sep 17 00:00:00 2001 From: boyeon Date: Tue, 14 Apr 2026 14:17:19 +0900 Subject: [PATCH 66/77] =?UTF-8?q?refactor:=20=EB=8D=94=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=20=EB=B2=84=ED=8A=BC=20=EA=B4=80=EB=A0=A8=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/view/moreButton.ts | 22 ---------------------- src/view/movieList.ts | 3 --- 2 files changed, 25 deletions(-) delete mode 100644 src/view/moreButton.ts diff --git a/src/view/moreButton.ts b/src/view/moreButton.ts deleted file mode 100644 index 97928b386d..0000000000 --- a/src/view/moreButton.ts +++ /dev/null @@ -1,22 +0,0 @@ -export const renderMoreButton = () => { - const moreButton = document.querySelector("#more-button"); - if (!moreButton) return null; - - moreButton.style.display = "block"; -}; - -export const removeMoreButton = () => { - const moreButton = document.querySelector("#more-button"); - if (!moreButton) return null; - - moreButton.style.display = "none"; -}; - -export const updateMoreButton = (currentPage: number, totalPages: number) => { - if (currentPage === totalPages) { - removeMoreButton(); - return; - } - - renderMoreButton(); -}; diff --git a/src/view/movieList.ts b/src/view/movieList.ts index 0cb194ee82..cab576673a 100644 --- a/src/view/movieList.ts +++ b/src/view/movieList.ts @@ -1,5 +1,4 @@ import { Movie, Movies } from "../services/dto"; -import { removeMoreButton } from "./moreButton"; const createMovieNode = (movie: Movie): DocumentFragment | null => { const movieTemplate = @@ -48,8 +47,6 @@ export const renderNoResult = () => { 검색 결과가 없습니다.

                  `; noResult.innerHTML = empty; - - removeMoreButton(); }; export const removeMovieList = () => { From 2933c1e0156ac3955eb3c53a9d71b3915fa8badb Mon Sep 17 00:00:00 2001 From: boyeon Date: Tue, 14 Apr 2026 21:09:24 +0900 Subject: [PATCH 67/77] =?UTF-8?q?refactor:=20=ED=8F=89=EC=A0=90=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=83=81=EC=88=98=EB=A5=BC=20readonly?= =?UTF-8?q?=EB=A1=9C=20=EA=B3=A0=EC=A0=95=ED=95=98=EA=B3=A0=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/constants/rating.ts | 10 ++++++++-- src/handlers/ratingHandler.ts | 8 ++++++-- src/view/movieDetail.ts | 12 ++++++++---- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/constants/rating.ts b/src/constants/rating.ts index 32f106a631..d3f946a2b8 100644 --- a/src/constants/rating.ts +++ b/src/constants/rating.ts @@ -4,6 +4,12 @@ export const RATING_TEXTS = [ "보통이에요", "재밌어요", "명작이에요", -]; +] as const; -export const RATING_SCORES = ["2", "4", "6", "8", "10"]; +export const RATING_SCORES = ["2", "4", "6", "8", "10"] as const; + +export type RatingScore = (typeof RATING_SCORES)[number]; + +export const isRatingScore = (value: string | null): value is RatingScore => { + return value !== null && RATING_SCORES.includes(value as RatingScore); +}; diff --git a/src/handlers/ratingHandler.ts b/src/handlers/ratingHandler.ts index 7c208dfc20..c393eb29e9 100644 --- a/src/handlers/ratingHandler.ts +++ b/src/handlers/ratingHandler.ts @@ -1,8 +1,11 @@ -import { RATING_SCORES } from "../constants/rating"; +import { isRatingScore, RATING_SCORES } from "../constants/rating"; import { setLocalStorage } from "../services/storage"; import { fillStars, updateRatingResult } from "../view/movieDetail"; export const handleRatingStarClick = (index: number) => { + const ratingScore = RATING_SCORES[index]; + if (!isRatingScore(ratingScore)) return; + fillStars(index); updateRatingResult(index); @@ -10,5 +13,6 @@ export const handleRatingStarClick = (index: number) => { if (!movieModal) return; const movieId = movieModal.dataset.movieId; if (!movieId) return; - setLocalStorage(movieId, RATING_SCORES[index]); + + setLocalStorage(movieId, ratingScore); }; diff --git a/src/view/movieDetail.ts b/src/view/movieDetail.ts index 3d24e90810..cf774696f1 100644 --- a/src/view/movieDetail.ts +++ b/src/view/movieDetail.ts @@ -1,4 +1,8 @@ -import { RATING_SCORES, RATING_TEXTS } from "../constants/rating"; +import { + isRatingScore, + RATING_SCORES, + RATING_TEXTS, +} from "../constants/rating"; import { MovieDetail } from "../services/dto"; import { getLocalStorage } from "../services/storage"; @@ -31,10 +35,10 @@ export const renderMovieDetail = (movieDetail: MovieDetail) => { if (rate) rate.textContent = movieDetail.vote_average.toString(); if (detail) detail.textContent = movieDetail.overview; - const ratingScore = getLocalStorage(String(movieDetail.id)); - if (!ratingScore) return; + const savedRating = getLocalStorage(String(movieDetail.id)); + if (!isRatingScore(savedRating)) return; - const index = RATING_SCORES.indexOf(ratingScore); + const index = RATING_SCORES.indexOf(savedRating); fillStars(index); updateRatingResult(index); }; From 1eede06401fd28e351ed2a38d046864163774b33 Mon Sep 17 00:00:00 2001 From: boyeon Date: Tue, 14 Apr 2026 22:04:50 +0900 Subject: [PATCH 68/77] =?UTF-8?q?test:=20=EC=98=81=ED=99=94=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B3=B5=ED=86=B5=20=ED=97=AC=ED=8D=BC=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20-=20API=20=EB=AA=A9=ED=82=B9=20-=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=97=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cypress/e2e/error-handling.cy.ts | 16 +++--- cypress/e2e/movie-list-rendering.cy.ts | 33 +++++------- cypress/e2e/movie-modal.cy.ts | 33 +++++------- cypress/e2e/movie-rating.cy.ts | 36 ++++++------- cypress/e2e/movie-search.cy.ts | 43 ++++++--------- cypress/support/movie.ts | 75 ++++++++++++++++++++++++++ 6 files changed, 140 insertions(+), 96 deletions(-) create mode 100644 cypress/support/movie.ts diff --git a/cypress/e2e/error-handling.cy.ts b/cypress/e2e/error-handling.cy.ts index f84b3994b3..df034e46b2 100644 --- a/cypress/e2e/error-handling.cy.ts +++ b/cypress/e2e/error-handling.cy.ts @@ -1,16 +1,14 @@ import { moviesFixture } from "../../test/fixtures"; +import { mockPopularPage } from "../support/movie"; describe("오류 대응 테스트", () => { beforeEach(() => { - cy.intercept("GET", "**/movie/popular?page=1&language=ko-KR", { - statusCode: 200, - body: { - page: 1, - results: [...moviesFixture], - total_pages: 2, - total_results: 40, - }, - }).as("getPopularPage1"); + mockPopularPage({ + page: 1, + results: moviesFixture, + totalPages: 2, + totalResults: 40, + }); cy.intercept("GET", "**/movie/popular?page=2&language=ko-KR", { statusCode: 400, diff --git a/cypress/e2e/movie-list-rendering.cy.ts b/cypress/e2e/movie-list-rendering.cy.ts index cedee7503c..4256d87f28 100644 --- a/cypress/e2e/movie-list-rendering.cy.ts +++ b/cypress/e2e/movie-list-rendering.cy.ts @@ -1,26 +1,21 @@ import { moviesFixture } from "../../test/fixtures"; +import { mockPopularPage } from "../support/movie"; describe("영화 목록 조회 기능 테스트", () => { beforeEach(() => { - cy.intercept("GET", "**/movie/popular?page=1&language=ko-KR", { - statusCode: 200, - body: { - page: 1, - results: [...moviesFixture], - total_pages: 2, - total_results: 40, - }, - }).as("getPopularPage1"); - - cy.intercept("GET", "**/movie/popular?page=2&language=ko-KR", { - statusCode: 200, - body: { - page: 2, - results: [...moviesFixture], - total_pages: 2, - total_results: 40, - }, - }).as("getPopularPage2"); + mockPopularPage({ + page: 1, + results: moviesFixture, + totalPages: 2, + totalResults: 40, + }); + + mockPopularPage({ + page: 2, + results: moviesFixture, + totalPages: 2, + totalResults: 40, + }); cy.visit("localhost:5173"); cy.wait("@getPopularPage1"); diff --git a/cypress/e2e/movie-modal.cy.ts b/cypress/e2e/movie-modal.cy.ts index f895811303..e8c7d2587a 100644 --- a/cypress/e2e/movie-modal.cy.ts +++ b/cypress/e2e/movie-modal.cy.ts @@ -1,27 +1,20 @@ import { movieDetailFixture, moviesFixture } from "../../test/fixtures"; +import { + mockMovieDetail, + mockPopularPage, + openMovieModal, +} from "../support/movie"; describe("영화 모달 기능 테스트", () => { - const openMovieModal = () => { - cy.get("#movie-list li").first().click(); - cy.wait("@getMovieDetail"); - cy.get("#modal-background").should("have.class", "active"); - }; - beforeEach(() => { - cy.intercept("GET", "**/movie/popular?page=1&language=ko-KR", { - statusCode: 200, - body: { - page: 1, - results: [...moviesFixture], - total_pages: 2, - total_results: 40, - }, - }).as("getPopularPage1"); - - cy.intercept("GET", "**/movie/640146?language=ko-KR", { - statusCode: 200, - body: movieDetailFixture, - }).as("getMovieDetail"); + mockPopularPage({ + page: 1, + results: moviesFixture, + totalPages: 2, + totalResults: 40, + }); + + mockMovieDetail(moviesFixture[0].id, movieDetailFixture); cy.visit("localhost:5173"); cy.wait("@getPopularPage1"); diff --git a/cypress/e2e/movie-rating.cy.ts b/cypress/e2e/movie-rating.cy.ts index a7119a3643..998f06d157 100644 --- a/cypress/e2e/movie-rating.cy.ts +++ b/cypress/e2e/movie-rating.cy.ts @@ -1,31 +1,25 @@ -import { movieDetailFixture, moviesFixture } from "../../test/fixtures"; import { RATING_SCORES, RATING_TEXTS } from "../../src/constants/rating"; +import { movieDetailFixture, moviesFixture } from "../../test/fixtures"; +import { + mockPopularPage, + mockMovieDetail, + openMovieModal, +} from "../support/movie"; describe("영화 별점 기능 테스트", () => { - const openMovieModal = () => { - cy.get("#movie-list li").first().click(); - cy.wait("@getMovieDetail"); - cy.get("#modal-background").should("have.class", "active"); - }; - beforeEach(() => { - cy.intercept("GET", "**/movie/popular?page=1&language=ko-KR", { - statusCode: 200, - body: { - page: 1, - results: [...moviesFixture], - total_pages: 2, - total_results: 40, - }, - }).as("getPopularPage1"); - - cy.intercept("GET", "**/movie/640146?language=ko-KR", { - statusCode: 200, - body: movieDetailFixture, - }).as("getMovieDetail"); + mockPopularPage({ + page: 1, + results: moviesFixture, + totalPages: 2, + totalResults: 40, + }); + + mockMovieDetail(moviesFixture[0].id, movieDetailFixture); cy.visit("localhost:5173"); cy.wait("@getPopularPage1"); + cy.clearLocalStorage(); }); diff --git a/cypress/e2e/movie-search.cy.ts b/cypress/e2e/movie-search.cy.ts index ace48a829d..4aa52a34a1 100644 --- a/cypress/e2e/movie-search.cy.ts +++ b/cypress/e2e/movie-search.cy.ts @@ -1,34 +1,23 @@ import { searchFixture } from "../../test/fixtures"; +import { mockSearchPage } from "../support/movie"; describe("영화 검색 기능 테스트", () => { beforeEach(() => { - cy.intercept( - "GET", - "**/search/movie?page=1&query=%EC%8A%A4%ED%8C%8C%EC%9D%B4&language=ko-KR", - { - statusCode: 200, - body: { - page: 1, - results: [...searchFixture], - total_pages: 2, - total_results: 40, - }, - }, - ).as("getSearchPage1"); - - cy.intercept( - "GET", - "**/search/movie?page=2&query=%EC%8A%A4%ED%8C%8C%EC%9D%B4&language=ko-KR", - { - statusCode: 200, - body: { - page: 2, - results: [...searchFixture], - total_pages: 2, - total_results: 40, - }, - }, - ).as("getSearchPage2"); + mockSearchPage({ + query: "스파이", + page: 1, + results: searchFixture, + totalPages: 2, + totalResults: 40, + }); + + mockSearchPage({ + query: "스파이", + page: 2, + results: searchFixture, + totalPages: 2, + totalResults: 40, + }); cy.intercept( "GET", diff --git a/cypress/support/movie.ts b/cypress/support/movie.ts new file mode 100644 index 0000000000..9c08661dd5 --- /dev/null +++ b/cypress/support/movie.ts @@ -0,0 +1,75 @@ +import { + movieDetailFixture, + moviesFixture, + searchFixture, +} from "../../test/fixtures"; + +interface MockPopularPageParams { + page: number; + results: typeof moviesFixture; + totalPages: number; + totalResults: number; +} + +interface MockSearchPageParams { + query: string; + page: number; + results: typeof searchFixture; + totalPages: number; + totalResults: number; +} + +export const mockPopularPage = ({ + page, + results, + totalPages, + totalResults, +}: MockPopularPageParams) => { + cy.intercept("GET", `**/movie/popular?page=${page}&language=ko-KR`, { + statusCode: 200, + body: { + page, + results: [...results], + total_pages: totalPages, + total_results: totalResults, + }, + }).as(`getPopularPage${page}`); +}; + +export const mockSearchPage = ({ + query, + page, + results, + totalPages, + totalResults, +}: MockSearchPageParams) => { + cy.intercept( + "GET", + `**/search/movie?page=${page}&query=${encodeURIComponent(query)}&language=ko-KR`, + { + statusCode: 200, + body: { + page, + results: [...results], + total_pages: totalPages, + total_results: totalResults, + }, + }, + ).as(`getSearchPage${page}`); +}; + +export const mockMovieDetail = ( + movieId: number, + body: typeof movieDetailFixture, +) => { + cy.intercept("GET", `**/movie/${movieId}?language=ko-KR`, { + statusCode: 200, + body, + }).as("getMovieDetail"); +}; + +export const openMovieModal = () => { + cy.get("#movie-list li").first().click(); + cy.wait("@getMovieDetail"); + cy.get("#modal-background").should("have.class", "active"); +}; From c12260f3d9f5cd1cc9522b12a5a16d93ac8b63d2 Mon Sep 17 00:00:00 2001 From: boyeon Date: Tue, 14 Apr 2026 22:17:43 +0900 Subject: [PATCH 69/77] =?UTF-8?q?test:=20=EC=98=81=ED=99=94=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EB=AA=A8=ED=82=B9=EA=B3=BC=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EC=97=B4=EA=B8=B0=EA=B0=80=20=EA=B0=99=EC=9D=80=20fixture?= =?UTF-8?q?=EB=A5=BC=20=EC=B0=B8=EC=A1=B0=ED=95=98=EB=8F=84=EB=A1=9D=20ind?= =?UTF-8?q?ex=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cypress/e2e/movie-modal.cy.ts | 10 +++++----- cypress/e2e/movie-rating.cy.ts | 8 ++++---- cypress/support/movie.ts | 8 +++++--- test/fixtures.ts | 2 +- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/cypress/e2e/movie-modal.cy.ts b/cypress/e2e/movie-modal.cy.ts index e8c7d2587a..b151d28d65 100644 --- a/cypress/e2e/movie-modal.cy.ts +++ b/cypress/e2e/movie-modal.cy.ts @@ -14,35 +14,35 @@ describe("영화 모달 기능 테스트", () => { totalResults: 40, }); - mockMovieDetail(moviesFixture[0].id, movieDetailFixture); + mockMovieDetail(0, movieDetailFixture); cy.visit("localhost:5173"); cy.wait("@getPopularPage1"); }); it("영화 목록을 클릭하면 모달이 뜨고 닫기 버튼으로 닫을 수 있다", () => { - openMovieModal(); + openMovieModal(0); cy.get("#close-modal").click(); cy.get("#modal-background").should("not.have.class", "active"); }); it("ESC 버튼을 누르면 모달이 닫힌다", () => { - openMovieModal(); + openMovieModal(0); cy.get("body").type("{esc}"); cy.get("#modal-background").should("not.have.class", "active"); }); it("모달창이 아닌 부분을 클릭하면 모달이 닫힌다", () => { - openMovieModal(); + openMovieModal(0); cy.get("#modal-background").click("topLeft"); cy.get("#modal-background").should("not.have.class", "active"); }); it("모달창이 뜨면 배경 스크롤이 적용되지 않는다", () => { - openMovieModal(); + openMovieModal(0); cy.get("body").should("have.class", "modal-open"); }); diff --git a/cypress/e2e/movie-rating.cy.ts b/cypress/e2e/movie-rating.cy.ts index 998f06d157..dd4e24fa82 100644 --- a/cypress/e2e/movie-rating.cy.ts +++ b/cypress/e2e/movie-rating.cy.ts @@ -15,7 +15,7 @@ describe("영화 별점 기능 테스트", () => { totalResults: 40, }); - mockMovieDetail(moviesFixture[0].id, movieDetailFixture); + mockMovieDetail(0, movieDetailFixture); cy.visit("localhost:5173"); cy.wait("@getPopularPage1"); @@ -24,7 +24,7 @@ describe("영화 별점 기능 테스트", () => { }); it("모달창에서 별점을 클릭하면 해당 별만큼 채워지고 점수와 평가 문구가 바뀐다", () => { - openMovieModal(); + openMovieModal(0); cy.get(".movie-rating .stars img").eq(3).click(); @@ -48,12 +48,12 @@ describe("영화 별점 기능 테스트", () => { }); it("별점을 매기고 다시 모달을 열면 이전 별점이 유지된다", () => { - openMovieModal(); + openMovieModal(0); cy.get(".movie-rating .stars img").eq(3).click(); cy.get("#close-modal").click(); - openMovieModal(); + openMovieModal(0); cy.get(".rating-text").should("have.text", RATING_TEXTS[3]); cy.get("#rating-value").should("have.text", RATING_SCORES[3]); diff --git a/cypress/support/movie.ts b/cypress/support/movie.ts index 9c08661dd5..b4ea309222 100644 --- a/cypress/support/movie.ts +++ b/cypress/support/movie.ts @@ -59,17 +59,19 @@ export const mockSearchPage = ({ }; export const mockMovieDetail = ( - movieId: number, + movieIndex: number, body: typeof movieDetailFixture, ) => { + const movieId = moviesFixture[movieIndex].id; + cy.intercept("GET", `**/movie/${movieId}?language=ko-KR`, { statusCode: 200, body, }).as("getMovieDetail"); }; -export const openMovieModal = () => { - cy.get("#movie-list li").first().click(); +export const openMovieModal = (movieIndex: number) => { + cy.get("#movie-list li").eq(movieIndex).click(); cy.wait("@getMovieDetail"); cy.get("#modal-background").should("have.class", "active"); }; diff --git a/test/fixtures.ts b/test/fixtures.ts index b55a21cb38..f8e8f34b0d 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -685,7 +685,7 @@ export const searchFixture = [ ]; export const movieDetailFixture = { - id: 640146, + id: moviesFixture[0].id, title: "앤트맨과 와스프: 퀀텀매니아", poster_path: "/ngl2FKBlU4fhbdsrtdom9LVLBXw.jpg", overview: From 4169199f86db305a03e3167f3346d7292bac8622 Mon Sep 17 00:00:00 2001 From: boyeon Date: Tue, 14 Apr 2026 23:26:59 +0900 Subject: [PATCH 70/77] =?UTF-8?q?feat:=20=EC=98=81=ED=99=94=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EB=AA=A8=EB=8B=AC=20=EB=A1=9C=EB=94=A9=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=88=EB=A0=88=ED=86=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 2 +- public/styles/modal.css | 78 ++++++++++++++++++++++++++++++++++++ src/handlers/modalHandler.ts | 4 +- src/movieLoader.ts | 16 +++++--- src/view/movieDetail.ts | 9 +++++ src/view/skeleton.ts | 20 +++++++-- 6 files changed, 118 insertions(+), 11 deletions(-) diff --git a/index.html b/index.html index e33adfe43b..2eb91c407b 100644 --- a/index.html +++ b/index.html @@ -62,7 +62,7 @@

                  지금 인기 있는 영화

                  -