diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..b2c4bee72 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,12 @@ +{ + "permissions": { + "allow": [ + "Bash(git fetch:*)", + "Bash(git ls-remote:*)", + "Bash(git rebase:*)", + "Bash(git merge:*)", + "Bash(git checkout:*)", + "Bash(git rm:*)" + ] + } +} diff --git a/README.md b/README.md index 9d4e03294..bab728fd7 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,32 @@ FE 레벨1 영화 리뷰 미션 - [x] 결과 없음 처리: 검색 결과가 존재하지 않을 경우 "검색 결과가 없습니다"라는 안내 메시지를 출력합니다. +3. 영화 상세 정보 조회 + +- [x] 모달 열기: 영화 포스터 또는 제목 클릭 시 상세 정보 모달 창을 표시합니다. + +- [x] 모달 닫기: ESC 키 입력 또는 모달 외부 클릭 시 모달을 닫습니다. + +- [x] 상세 정보 표시: API에서 제공하는 항목(제목, 장르, 줄거리, 평점 등)을 모달에 표시합니다. + +4. 별점 매기기 + +- [x] 별점 입력: 사용자가 영화에 대해 1~5개의 별(2점 단위, 최대 10점)을 선택하여 별점을 줄 수 있습니다. + +- [x] 별점 유지: 새로고침 후에도 사용자가 남긴 별점이 유지됩니다. (localStorage 사용) + +- [x] 별점 표시: 2점=최악이에요, 4점=별로에요, 6점=보통이에요, 8점=재미있어요, 10점=명작이에요 + +5. UI/UX 개선 + +- [x] 반응형 레이아웃: 디바이스 너비에 따라 영화 목록과 모달 레이아웃이 유동적으로 조절됩니다. + +- [x] 무한 스크롤: '더 보기' 버튼 방식을 무한 스크롤 방식으로 전환합니다. 화면 끝에 도달하면 다음 20개를 자동으로 불러옵니다. + +6. E2E 테스트 + +- [x] 핵심 사용자 시나리오를 정의하고 E2E 테스트를 작성합니다. + --- ## 주요 스펙 및 구현 결정사항 @@ -44,20 +70,23 @@ FE 레벨1 영화 리뷰 미션 - 검색 결과도 인기 영화 목록과 동일하게 페이지네이션이 적용됩니다. - 검색 결과가 없는 경우 "검색 결과가 없습니다." 메시지를 표시합니다. -### 오류 처리 +### 영화 상세 정보 조회 -오류가 발생하는 경우를 다음과 같이 정의하고 대응했습니다. +- 영화 포스터 또는 제목 클릭 시 모달 창이 열립니다. +- ESC 키 또는 모달 외부 클릭으로 모달을 닫을 수 있습니다. +- API에서 제공하는 제목, 장르, 줄거리, 평점 등의 항목을 표시합니다. -| 오류 케이스 | 처리 방식 | -| ---------------------------- | ------------------------------------------------------------------------------- | -| 인기 영화 API 호출 실패 | `alert()`으로 사용자에게 직접 알림 | -| 검색 API 호출 실패 | `alert()`으로 사용자에게 직접 알림 | -| 영화 포스터 이미지 로드 실패 | 대체 이미지(`no_image.png`)로 교체 후 Skeleton 제거 | -| poster_path가 null인 경우 | TMDB가 200으로 응답하지만 이미지 로드 실패로 처리하여 동일하게 대체 이미지 적용 | +### 별점 매기기 -`alert()`을 선택한 이유는 API 호출 실패는 사용자가 즉시 인지해야 하는 상황이라고 판단했기 때문입니다. +- 별점은 5개(최대 10점)로 구성되며 1개당 2점입니다. +- localStorage에 저장하여 새로고침 후에도 별점이 유지됩니다. ---- +### 무한 스크롤 + +- 기존 '더 보기' 버튼 방식을 무한 스크롤로 전환합니다. +- 인기 영화 목록 및 검색 결과 모두 무한 스크롤이 적용됩니다. +- 마지막 페이지 도달 시 추가 요청을 중단합니다. + +### 반응형 레이아웃 -## 모듈 관계도 -Image +- Figma 시안을 기준으로 디바이스 너비에 따라 영화 목록과 모달의 레이아웃을 조절합니다. diff --git a/cypress/e2e/modal.cy.ts b/cypress/e2e/modal.cy.ts new file mode 100644 index 000000000..d00ddc9af --- /dev/null +++ b/cypress/e2e/modal.cy.ts @@ -0,0 +1,98 @@ +describe("모달 열기 테스트", () => { + beforeEach(() => { + cy.intercept("GET", "**/movie/popular**", { fixture: "movies.json" }).as( + "getMovies", + ); + cy.intercept("GET", "**/movie/1001**", { fixture: "moviedetail.json" }).as( + "getMovieDetail", + ); + cy.visit("http://localhost:5173"); + cy.wait("@getMovies"); + }); + + it("영화 아이템을 클릭하면 모달이 열린다", () => { + cy.get(".thumbnail-list li").first().find(".item").click(); + cy.wait("@getMovieDetail"); + cy.get("#modalBackground").should("have.class", "active"); + cy.get(".modal-container h2").should("contain", "영화 1"); + }); +}); + +describe("모달 닫기 테스트", () => { + beforeEach(() => { + cy.intercept("GET", "**/movie/popular**", { fixture: "movies.json" }).as( + "getMovies", + ); + cy.intercept("GET", "**/movie/1001**", { fixture: "moviedetail.json" }).as( + "getMovieDetail", + ); + cy.visit("http://localhost:5173"); + cy.wait("@getMovies"); + cy.get(".thumbnail-list li").first().find(".item").click(); + cy.wait("@getMovieDetail"); + }); + + it("닫기 버튼 클릭 시 모달이 닫힌다", () => { + cy.get("#closeModal").click(); + cy.get("#modalBackground").should("not.have.class", "active"); + cy.get(".modal-container").should("not.exist"); + }); + + it("Escape 키 입력 시 모달이 닫힌다", () => { + cy.get("body").type("{esc}"); + cy.get("#modalBackground").should("not.have.class", "active"); + cy.get(".modal-container").should("not.exist"); + }); + + it("배경 클릭 시 모달이 닫힌다", () => { + cy.get("#modalBackground").click({ force: true }); + cy.get("#modalBackground").should("not.have.class", "active"); + cy.get(".modal-container").should("not.exist"); + }); +}); + +describe("모달 반응형 테스트 - 태블릿", () => { + beforeEach(() => { + cy.viewport(768, 1024); + cy.intercept("GET", "**/movie/popular**", { fixture: "movies.json" }).as( + "getMovies", + ); + cy.intercept("GET", "**/movie/1001**", { fixture: "moviedetail.json" }).as( + "getMovieDetail", + ); + cy.visit("http://localhost:5173"); + cy.wait("@getMovies"); + cy.get(".thumbnail-list li").first().find(".item").click(); + cy.wait("@getMovieDetail"); + }); + + it("태블릿 화면에서 모달이 하단 고정으로 표시된다", () => { + cy.get("#modalBackground").should("have.css", "align-items", "flex-end"); + cy.get(".modal").should("have.css", "width", "768px"); + }); + + it("태블릿 화면에서 포스터와 설명이 세로 정렬된다", () => { + cy.get(".modal-container").should("have.css", "flex-direction", "column"); + cy.get(".modal-image img").should("have.css", "width", "160px"); + }); +}); + +describe("모달 반응형 테스트 - 모바일", () => { + beforeEach(() => { + cy.viewport(375, 667); + cy.intercept("GET", "**/movie/popular**", { fixture: "movies.json" }).as( + "getMovies", + ); + cy.intercept("GET", "**/movie/1001**", { fixture: "moviedetail.json" }).as( + "getMovieDetail", + ); + cy.visit("http://localhost:5173"); + cy.wait("@getMovies"); + cy.get(".thumbnail-list li").first().find(".item").click(); + cy.wait("@getMovieDetail"); + }); + + it("모바일 화면에서 포스터 이미지가 숨겨진다", () => { + cy.get(".modal-image").should("have.css", "display", "none"); + }); +}); diff --git a/cypress/e2e/spec.cy.ts b/cypress/e2e/movie.cy.ts similarity index 71% rename from cypress/e2e/spec.cy.ts rename to cypress/e2e/movie.cy.ts index 130bf90af..79675c97a 100644 --- a/cypress/e2e/spec.cy.ts +++ b/cypress/e2e/movie.cy.ts @@ -1,40 +1,44 @@ describe("인기영화 렌더링 테스트", () => { - beforeEach(() => { - cy.visit("localhost:5173"); - }); - it("웹에 접근을 하면 인기 영화 20개를 랜더링 한다", () => { + // page=2 자동 로드를 막아 20개만 렌더링되도록 intercept + cy.intercept( + "GET", + "https://api.themoviedb.org/3/movie/popular?language=ko-KR&page=2", + { body: { results: [], total_pages: 1 } }, + ); + cy.visit("localhost:5173"); cy.get(".thumbnail-list li").should("have.length", 20); }); - it("인기 영화 화면에서 더보기 버튼을 누르면 인기 영화 20개를 추가로 렌더링 한다", () => { - cy.get("#load-movie-button").click(); + it("인기 영화 화면에서 화면의 끝에 도달하면 인기 영화 20개를 추가로 렌더링 한다", () => { + cy.visit("localhost:5173"); + cy.get("#scroll-sentinel").scrollIntoView(); cy.get(".thumbnail-list li").should("have.length", 40); }); }); -describe("인기 영화 더보기 버튼이 숨겨지는지 테스트", () => { +describe("인기 영화 무한스크롤 테스트", () => { beforeEach(() => { cy.intercept( "GET", - "https://api.themoviedb.org/3/movie/popular?language=en-US&page=1", + "https://api.themoviedb.org/3/movie/popular?language=ko-KR&page=1", { fixture: "movies.json" }, ).as("getMovies"); cy.intercept( "GET", - "https://api.themoviedb.org/3/movie/popular?language=en-US&page=2", + "https://api.themoviedb.org/3/movie/popular?language=ko-KR&page=2", { fixture: "movies2.json" }, ).as("getMoviesPage2"); cy.visit("http://localhost:5173"); }); - it("마지막 페이지 도달 시 더보기 버튼이 사라진다", () => { + it("마지막 페이지 도달 시 스크롤 시에 영화를 더 불러오지 않는다.", () => { cy.wait("@getMovies"); - cy.get("#load-movie-button").click(); + cy.get("#scroll-sentinel").scrollIntoView(); cy.wait("@getMoviesPage2"); - cy.get("#load-movie-button").should("have.css", "display", "none"); + cy.get(".thumbnail-list li").should("have.length", 40); }); }); @@ -57,8 +61,14 @@ describe("검색영화 렌더링 테스트", () => { }); }); -describe("검색 영화 더보기 버튼이 숨겨지는지 테스트", () => { +describe("검색 영화 무한스크롤 테스트", () => { beforeEach(() => { + cy.intercept( + "GET", + "https://api.themoviedb.org/3/movie/popular?language=ko-KR&page=2", + { body: { results: [], total_pages: 1 } }, + ); + cy.intercept("GET", "**/search/movie*page=1*", { fixture: "movies.json", }).as("getMovies"); @@ -70,13 +80,13 @@ describe("검색 영화 더보기 버튼이 숨겨지는지 테스트", () => { cy.visit("http://localhost:5173"); }); - it("마지막 페이지 도달 시 더보기 버튼이 사라진다", () => { + it("마지막 페이지 도달 시 스크롤 시에 영화를 더 불러오지 않는다.", () => { cy.get(".search-input").type("영화"); cy.get(".search-input").type("{enter}"); cy.wait("@getMovies"); - cy.get("#load-movie-button").click(); + cy.get("#scroll-sentinel").scrollIntoView(); cy.wait("@getMoviesPage2"); - cy.get("#load-movie-button").should("have.css", "display", "none"); + cy.get(".thumbnail-list li").should("have.length", 40); }); }); @@ -84,7 +94,7 @@ describe("Skeleton UI 테스트", () => { it("이미지 로드 전 스켈레톤 UI가 표시된다", () => { cy.intercept( "GET", - "https://api.themoviedb.org/3/movie/popular?language=en-US&page=1", + "https://api.themoviedb.org/3/movie/popular?language=ko-KR&page=1", { fixture: "movies.json" }, ).as("getMovies"); @@ -110,7 +120,7 @@ describe("Skeleton UI 테스트", () => { it("이미지 로드 실패 시 스켈레톤 UI가 제거되고 대체 이미지가 표시된다", () => { cy.intercept( "GET", - "https://api.themoviedb.org/3/movie/popular?language=en-US&page=1", + "https://api.themoviedb.org/3/movie/popular?language=ko-KR&page=1", { fixture: "movies.json" }, ).as("getMovies"); diff --git a/cypress/e2e/rating.cy.ts b/cypress/e2e/rating.cy.ts new file mode 100644 index 000000000..5bdabf616 --- /dev/null +++ b/cypress/e2e/rating.cy.ts @@ -0,0 +1,37 @@ +describe("로컬스토리지 평점 저장 테스트", () => { + beforeEach(() => { + cy.clearLocalStorage(); + cy.intercept("GET", "**/movie/popular**", { fixture: "movies.json" }).as( + "getMovies", + ); + cy.intercept("GET", "**/movie/1001**", { fixture: "moviedetail.json" }).as( + "getMovieDetail", + ); + cy.visit("http://localhost:5173"); + cy.wait("@getMovies"); + cy.get(".thumbnail-list li").first().find(".item").click(); + cy.wait("@getMovieDetail"); + }); + + it("새로고침 후 같은 영화 모달을 열면 저장된 평점이 유지된다", () => { + cy.get(".my-rate-stars .my-star").eq(3).click(); + cy.reload(); + cy.wait("@getMovies"); + cy.get(".thumbnail-list li").first().find(".item").click(); + cy.wait("@getMovieDetail"); + + cy.get(".my-rate-stars .my-star").each((star, index) => { + if (index <= 3) { + cy.wrap(star) + .should("have.attr", "src") + .and("include", "star_filled.png"); + } else { + cy.wrap(star) + .should("have.attr", "src") + .and("include", "star_empty.png"); + } + }); + cy.get(".my-score-label").should("contain", "재미있어요"); + cy.get(".my-rate-value").should("contain", "(8/10)"); + }); +}); diff --git a/cypress/fixtures/moviedetail.json b/cypress/fixtures/moviedetail.json new file mode 100644 index 000000000..a4bd47b8b --- /dev/null +++ b/cypress/fixtures/moviedetail.json @@ -0,0 +1,13 @@ +{ + "id": 1001, + "title": "영화 1", + "poster_path": "/test0.jpg", + "backdrop_path": "/backdrop0.jpg", + "release_date": "2024-01-01", + "genres": [ + { "id": 28, "name": "액션" }, + { "id": 12, "name": "모험" } + ], + "vote_average": 7.5, + "overview": "테스트용 영화 줄거리입니다." +} diff --git a/cypress/fixtures/movies.json b/cypress/fixtures/movies.json index cbd429c1c..9ae3da312 100644 --- a/cypress/fixtures/movies.json +++ b/cypress/fixtures/movies.json @@ -2,120 +2,140 @@ "page": 1, "results": [ { + "id": 1001, "title": "영화 1", "vote_average": 1.0, "poster_path": "/test0.jpg", "backdrop_path": "/backdrop0.jpg" }, { + "id": 1002, "title": "영화 2", "vote_average": 2.0, "poster_path": "/test1.jpg", "backdrop_path": "/backdrop1.jpg" }, { + "id": 1003, "title": "영화 3", "vote_average": 3.0, "poster_path": "/test2.jpg", "backdrop_path": "/backdrop2.jpg" }, { + "id": 1004, "title": "영화 4", "vote_average": 4.0, "poster_path": "/test3.jpg", "backdrop_path": "/backdrop3.jpg" }, { + "id": 1005, "title": "영화 5", "vote_average": 5.0, "poster_path": "/test4.jpg", "backdrop_path": "/backdrop4.jpg" }, { + "id": 1006, "title": "영화 6", "vote_average": 6.0, "poster_path": "/test5.jpg", "backdrop_path": "/backdrop5.jpg" }, { + "id": 1007, "title": "영화 7", "vote_average": 7.0, "poster_path": "/test6.jpg", "backdrop_path": "/backdrop6.jpg" }, { + "id": 1008, "title": "영화 8", "vote_average": 8.0, "poster_path": "/test7.jpg", "backdrop_path": "/backdrop7.jpg" }, { + "id": 1009, "title": "영화 9", "vote_average": 9.0, "poster_path": "/test8.jpg", "backdrop_path": "/backdrop8.jpg" }, { + "id": 1010, "title": "영화 10", "vote_average": 10.0, "poster_path": "/test9.jpg", "backdrop_path": "/backdrop9.jpg" }, { + "id": 1011, "title": "영화 11", "vote_average": 1.0, "poster_path": "/test10.jpg", "backdrop_path": "/backdrop10.jpg" }, { + "id": 1012, "title": "영화 12", "vote_average": 2.0, "poster_path": "/test11.jpg", "backdrop_path": "/backdrop11.jpg" }, { + "id": 1013, "title": "영화 13", "vote_average": 3.0, "poster_path": "/test12.jpg", "backdrop_path": "/backdrop12.jpg" }, { + "id": 1014, "title": "영화 14", "vote_average": 4.0, "poster_path": "/test13.jpg", "backdrop_path": "/backdrop13.jpg" }, { + "id": 1015, "title": "영화 15", "vote_average": 5.0, "poster_path": "/test14.jpg", "backdrop_path": "/backdrop14.jpg" }, { + "id": 1016, "title": "영화 16", "vote_average": 6.0, "poster_path": "/test15.jpg", "backdrop_path": "/backdrop15.jpg" }, { + "id": 1017, "title": "영화 17", "vote_average": 7.0, "poster_path": "/test16.jpg", "backdrop_path": "/backdrop16.jpg" }, { + "id": 1018, "title": "영화 18", "vote_average": 8.0, "poster_path": "/test17.jpg", "backdrop_path": "/backdrop17.jpg" }, { + "id": 1019, "title": "영화 19", "vote_average": 9.0, "poster_path": "/test18.jpg", "backdrop_path": "/backdrop18.jpg" }, { + "id": 1020, "title": "영화 20", "vote_average": 9.0, "poster_path": "/test18.jpg", diff --git a/cypress/fixtures/movies2.json b/cypress/fixtures/movies2.json index 3cfaf3484..e94dcf231 100644 --- a/cypress/fixtures/movies2.json +++ b/cypress/fixtures/movies2.json @@ -1,12 +1,6 @@ { "page": 2, "results": [ - { - "title": "영화 20", - "vote_average": 10.0, - "poster_path": "/test19.jpg", - "backdrop_path": "/backdrop19.jpg" - }, { "title": "영화 21", "vote_average": 1.0, diff --git a/public/styles/main.css b/public/styles/main.css index 26e186d97..5720dacf0 100644 --- a/public/styles/main.css +++ b/public/styles/main.css @@ -44,19 +44,8 @@ button.primary { border-radius: 4px; } -#load-movie-button { - display: block; - width: 1280px; - height: 49px; - margin: 0 auto 4rem; - background-color: #0DA9FD; - color: white; - border-radius: 4px; - font-size: 16px; -} - #wrap { - min-width: 1440px; + min-width: 320px; min-height: 100vh; background-color: #242A32; display: flex; @@ -250,3 +239,65 @@ footer.footer p:not(:last-child) { letter-spacing: 0 !important; margin-top: 1rem; } + +/* ===================== + 태블릿 (768px ~ 1023px) + ===================== */ +@media (max-width: 1023px) { + .background-container { + height: auto; + padding: 24px 24px 32px; + } + + /* 로고를 서치바 위로 */ + .header-top { + flex-direction: column; + align-items: center; + gap: 16px; + } + + .header-top .search-bar { + position: static; + transform: none; + width: 100%; + max-width: 100%; + } + + .top-rated-movie { + margin-top: 32px; + } + + .title { + font-size: 2rem; + } + + .container { + padding: 0 24px; + } +} + +/* ===================== + 모바일 (~ 767px) + ===================== */ +@media (max-width: 767px) { + .background-container { + padding: 16px 16px 24px; + } + + .title { + font-size: 1.5rem; + } + + #section-title { + font-size: 24px; + margin-bottom: 2rem; + } + + .container { + padding: 0 16px; + } + + main { + margin: 40px 0; + } +} diff --git a/public/styles/modal.css b/public/styles/modal.css index 240a7a37c..7425b0f17 100644 --- a/public/styles/modal.css +++ b/public/styles/modal.css @@ -65,24 +65,174 @@ body.modal-open { width: 100%; padding: 8px; margin-left: 16px; - line-height: 1.6rem; + display: flex; + flex-direction: column; + gap: 8px; +} + +.modal-description h2 { + font-size: 2rem; + font-weight: bold; + color: var(--color-white); + margin: 0; +} + +/* 구분선 */ +.modal-description hr { + border: none; + border-top: 1px solid #4a5568; + margin: 1rem 0; +} + +/* 출시년도, 장르 */ +.modal-description .category { + font-size: 20px; + font-weight: 400; + color: var(--color-white); +} + +/* 평균 */ +.modal-description .rate { + display: flex; + align-items: center !important; + gap: 6px; } .modal-description .rate > img { - position: relative; - top: 5px; + position: static; + top: 0; } -.modal-description > *:not(:last-child) { - margin-bottom: 8px; +.modal-description .rate .rate-label { + font-size: 20px; + font-weight: 400; + color: var(--color-white); } -.modal-description h2 { - font-size: 2rem; - margin: 0 0 8px; +.modal-description .rate .rate-avg-value { + font-size: 20px; + font-weight: 400; + color: var(--color-white); +} + +/* 내 별점 타이틀 / 줄거리 타이틀 */ +.my-rate-title, +.detail-title { + font-size: 24px; + font-weight: 600; + color: var(--color-white); +} + +/* 내 별점 */ +.my-rate { + display: flex; + align-items: center; + gap: 12px; +} + +.my-rate-stars { + display: flex; + align-items: center; + gap: 4px; +} + +.my-rate-stars .my-star { + display: block; + cursor: pointer; + width: 32px; + height: 32px; +} + +.my-score { + display: flex; + align-items: center; + gap: 6px; +} + +.my-score-label { + font-size: 24px; + font-weight: 600; + color: var(--color-white); + margin-right: 4px; +} + +.my-rate-value { + font-size: 24px; + font-weight: 600; + color: #95a1b2; } +/* 줄거리 */ .detail { - max-height: 430px; + font-size: 24px; + font-weight: 400; + max-height: 300px; overflow-y: auto; } + +.modal-description .rate .rate-label, +.modal-description .rate .rate-avg-value, +.my-score-label, +.my-rate-value { + position: relative; + top: 2.5px; +} + +/* ===================== + 태블릿 (768px ~ 1023px) + ===================== */ +@media (max-width: 1023px) { + /* 모달 하단 고정 */ + .modal-background { + align-items: flex-end; + } + + .modal { + width: 100%; + border-radius: 16px 16px 0 0; + max-height: 85vh; + overflow-y: auto; + } + + /* 포스터 사진, 제목, 출시년도/장르, 평균 세로 정렬 + 가운데 정렬 */ + .modal-container { + flex-direction: column; + align-items: center; + } + + .modal-image img { + width: 160px; + border-radius: 12px; + } + + .modal-description { + margin-left: 0; + margin-top: 16px; + width: 100%; + } + + /* hr 위: 포스터, 제목, 출시년도/장르, 평균만 가운데 정렬 */ + .modal-description h2, + .modal-description .category, + .modal-description .rate { + text-align: center; + justify-content: center; + } + + .modal-description h2 { + font-size: 1.5rem; + } +} + +/* ===================== + 모바일 (~ 767px) + ===================== */ +@media (max-width: 767px) { + .modal-image { + display: none; + } + + .modal-description { + margin-top: 0; + } +} diff --git a/public/styles/thumbnail.css b/public/styles/thumbnail.css index 5a03fed02..ba578f46f 100644 --- a/public/styles/thumbnail.css +++ b/public/styles/thumbnail.css @@ -81,3 +81,35 @@ p.rate > span { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } + +/* ===================== + 태블릿 (768px ~ 1023px) + ===================== */ +@media (max-width: 1023px) { + .thumbnail-list { + grid-template-columns: repeat(3, 1fr); + gap: 32px; + } + + .thumbnail { + width: 100%; + height: auto; + aspect-ratio: 2 / 3; + } + + .skeleton-poster { + width: 100%; + height: auto; + aspect-ratio: 2 / 3; + } +} + +/* ===================== + 모바일 (~ 767px) + ===================== */ +@media (max-width: 767px) { + .thumbnail-list { + grid-template-columns: repeat(2, 1fr); + gap: 20px; + } +} diff --git a/src/App.ts b/src/App.ts index f5725cb01..8892b705d 100644 --- a/src/App.ts +++ b/src/App.ts @@ -1,11 +1,8 @@ import template from "../templates/index.html?raw"; import "../public/styles/index.css"; -import { - renderMovies, - renderSearchedMovies, - replaceBanner, -} from "./movieRenderer.ts"; -import AppState from "../src/AppState.ts"; +import AppState from "./AppState.ts"; +import MovieBrowseHandler from "./MovieBrowseHandler.ts"; +import ModalHandler from "./ModalHandler.ts"; class App { private state = new AppState(); @@ -16,102 +13,12 @@ class App { /\/images\//g, `${base}images/`, ); - renderMovies(this.state.moviePageCount); - this.addEventListeners(); + this.init(); } - addEventListeners() { - document.addEventListener("click", this.handleSearchButtonClick); - - document.addEventListener("keydown", this.handleSearchKeydown); - - // 더보기 흐름 이벤트 부착 - document - .querySelector("#load-movie-button")! - .addEventListener("click", this.handleLoadMoreClick); - } - - // 이벤트 핸들러 - private handleSearchButtonClick = (e: MouseEvent) => { - if ((e.target as HTMLElement).closest(".search-button")) { - this.handleSearchSubmit(); - } - }; - private handleSearchKeydown = (e: KeyboardEvent) => { - if ( - e.key === "Enter" && - (e.target as HTMLElement).closest(".search-input") - ) { - this.handleSearchSubmit(); - } - }; - private handleLoadMoreClick = () => { - this.handleSearch(); - }; - - // 검색 엔터 / 검색 버튼 시 렌더링 함수 - private handleSearchSubmit = async () => { - this.state.isSearched = true; - this.state.searchPageCount = 1; - this.state.totalSearchPages = 0; - this.showLoadButton(); - this.state.currentKeyword = - document.querySelector(".search-input")!.value; - - const list = document.querySelector(".thumbnail-list"); - if (list) list.replaceChildren(); - - const header = document.querySelector("#header"); - if (header) { - header.replaceChildren(); - replaceBanner(header, this.state.currentKeyword); - } - - this.state.totalSearchPages = await renderSearchedMovies( - this.state.currentKeyword, - this.state.searchPageCount, - ); - if (this.state.totalSearchPages === this.state.searchPageCount) { - this.hideLoadButton(); - } - - const sectionTitle = document.querySelector("#section-title"); - if (sectionTitle) { - sectionTitle.textContent = `"${this.state.currentKeyword}" 검색 결과`; - } - }; - - // 초기화면, 검색화면 분기에 따른 더보기 함수 - private handleSearch = async () => { - if (!this.state.isSearched) { - this.state.moviePageCount += 1; - const totalPopularPages = await renderMovies(this.state.moviePageCount); - if (totalPopularPages === this.state.moviePageCount) { - this.hideLoadButton(); - } - } - if (this.state.isSearched) { - this.state.searchPageCount += 1; - const totalSearchPages = await renderSearchedMovies( - this.state.currentKeyword, - this.state.searchPageCount, - ); - if (totalSearchPages === this.state.searchPageCount) { - this.hideLoadButton(); - } - } - }; - - private hideLoadButton() { - const loadMovieButton = - document.querySelector("#load-movie-button"); - if (loadMovieButton) loadMovieButton.style.display = "none"; - } - - private showLoadButton() { - const loadMovieButton = - document.querySelector("#load-movie-button"); - if (loadMovieButton) loadMovieButton.style.display = ""; + private async init() { + await new MovieBrowseHandler(this.state).init(); + new ModalHandler().init(); } } diff --git a/src/AppState.ts b/src/AppState.ts index 87cad095e..f783a90ad 100644 --- a/src/AppState.ts +++ b/src/AppState.ts @@ -2,7 +2,6 @@ class AppState { moviePageCount = 1; searchPageCount = 1; isSearched = false; - totalSearchPages = 0; currentKeyword = ""; } diff --git a/src/ModalHandler.ts b/src/ModalHandler.ts new file mode 100644 index 000000000..43560972c --- /dev/null +++ b/src/ModalHandler.ts @@ -0,0 +1,60 @@ +import { renderMovieDetail } from "./movieDetailRenderer"; + +class ModalHandler { + modalArea = document.querySelector("#modalBackground"); + + init() { + document.addEventListener("click", this.handleMovieClick); + document.addEventListener("click", this.handleModalCloseButtonClick); + document.addEventListener("click", this.handleModalCloseBackdrop); + document.addEventListener("keydown", this.handleModalCloseButtonKeyDown); + } + + // 모달 닫는 핸들러 + handleModalCloseButtonClick = (e: MouseEvent) => { + if ((e.target as HTMLElement).closest("#closeModal")) { + document.querySelector(".modal-container")?.remove(); + this.modalArea?.classList.remove("active"); + } + }; + + handleModalCloseButtonKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + document.querySelector(".modal-container")?.remove(); + this.modalArea?.classList.remove("active"); + } + }; + + handleModalCloseBackdrop = (e: MouseEvent) => { + if ((e.target as HTMLElement) === this.modalArea) { + document.querySelector(".modal-container")?.remove(); + this.modalArea?.classList.remove("active"); + } + }; + + handleMovieClick = async (e: MouseEvent) => { + if ((e.target as HTMLElement).closest(".item")) { + const movieId: number = Number( + (e.target as HTMLElement).closest("[data-id]")?.getAttribute("data-id"), + ); + document.querySelector(".modal-container")?.remove(); + await renderMovieDetail(movieId); + this.showModal(); + } + + if ((e.target as HTMLElement).closest(".primary.detail")) { + const movieId: number = Number( + (e.target as HTMLElement).closest("[data-id]")?.getAttribute("data-id"), + ); + document.querySelector(".modal-container")?.remove(); + await renderMovieDetail(movieId); + this.showModal(); + } + }; + + private showModal() { + this.modalArea?.classList.add("active"); + } +} + +export default ModalHandler; diff --git a/src/MovieBrowseHandler.ts b/src/MovieBrowseHandler.ts new file mode 100644 index 000000000..02cc21d28 --- /dev/null +++ b/src/MovieBrowseHandler.ts @@ -0,0 +1,90 @@ +import { + renderMovies, + renderSearchedMovies, + replaceBanner, +} from "./movieRenderer.ts"; +import AppState from "./AppState.ts"; +import { createScrollObserver } from "./utils/scrollObserver.ts"; + +class MovieBrowseHandler { + constructor(private state: AppState) {} + + handleSearchButtonClick = (e: MouseEvent) => { + if ((e.target as HTMLElement).closest(".search-button")) { + this.handleSearchSubmit(); + } + }; + + handleSearchKeydown = (e: KeyboardEvent) => { + if ( + e.key === "Enter" && + (e.target as HTMLElement).closest(".search-input") + ) { + this.handleSearchSubmit(); + } + }; + + async init() { + await renderMovies(this.state.moviePageCount); + + document.addEventListener("click", this.handleSearchButtonClick); + document.addEventListener("keydown", this.handleSearchKeydown); + + const sentinel = document.querySelector("#scroll-sentinel"); + if (sentinel) { + let cleanup: () => void; + cleanup = createScrollObserver(sentinel, async () => { + const isLastPage = await this.handleLoadMoreScroll(); + if (isLastPage) cleanup(); + }); + } + } + + handleLoadMoreScroll = async () => { + if (!this.state.isSearched) { + this.state.moviePageCount += 1; + const totalPopularPages = await renderMovies(this.state.moviePageCount); + if (totalPopularPages === this.state.moviePageCount) { + return true; + } + } else { + this.state.searchPageCount += 1; + const totalSearchPages = await renderSearchedMovies( + this.state.currentKeyword, + this.state.searchPageCount, + ); + if (totalSearchPages === this.state.searchPageCount) { + return true; + } + } + return false; + }; + + handleSearchSubmit = async () => { + this.state.isSearched = true; + this.state.searchPageCount = 1; + this.state.currentKeyword = + document.querySelector(".search-input")!.value; + + const list = document.querySelector(".thumbnail-list"); + if (list) list.replaceChildren(); + + const header = document.querySelector("#header"); + if (header) { + header.replaceChildren(); + replaceBanner(header, this.state.currentKeyword); + } + + await renderSearchedMovies( + this.state.currentKeyword, + this.state.searchPageCount, + ); + + const sectionTitle = document.querySelector("#section-title"); + if (sectionTitle) { + sectionTitle.textContent = `"${this.state.currentKeyword}" 검색 결과`; + } + }; +} + +export default MovieBrowseHandler; diff --git a/src/StarRating.ts b/src/StarRating.ts new file mode 100644 index 000000000..ab4ea418c --- /dev/null +++ b/src/StarRating.ts @@ -0,0 +1,127 @@ +import { RatingStorage } from "./storage/RatingStorage"; + +class StarRating { + private container: HTMLElement; + private movieId: number; + private currentScore: number = 0; + private hoverScore: number = 0; + private storage: RatingStorage; + private stars: HTMLElement[] = []; + + constructor(container: HTMLElement, movieId: number, storage: RatingStorage) { + this.container = container; + this.movieId = movieId; + this.storage = storage; + + // storage에서 현재 연 영화 별점 업데이트 + this.currentScore = this.storage.getRating(this.movieId); + + this.bindRatingEvents(); + this.updateRatingUI(); + } + + private bindRatingEvents() { + // 마우스 호버 이벤트 => image가 filled로 변경 혹은 다시 원래대로 + this.stars = Array.from( + this.container.querySelectorAll(".star.my-star"), + ); + + this.container.addEventListener("mouseover", (e: MouseEvent) => + this.handleMouseOver(e), + ); + this.container.addEventListener("mouseout", (e: MouseEvent) => + this.handleMouseOut(e), + ); + + // 클릭 이벤트 => image가 선택한 갯수대로 filled되고 텍스트 및 숫자 업데이트 + this.container.addEventListener("click", (e: MouseEvent) => + this.handleClick(e), + ); + } + + // 위에 이벤트리스너에 부착되는 이벤트 핸들러들 + private handleMouseOver(e: MouseEvent) { + if ((e.target as HTMLElement).closest(".star.my-star")) { + const targetStar = (e.target as HTMLElement).closest(".star.my-star"); + const targetStarIndex = this.stars.indexOf(targetStar as HTMLElement); + + this.hoverScore = (targetStarIndex + 1) * 2; + this.updateRatingUI(); + } + } + + private handleMouseOut(e: MouseEvent) { + if ((e.target as HTMLElement).closest(".star.my-star")) { + this.hoverScore = 0; + this.updateRatingUI(); + } + } + + private handleClick(e: MouseEvent) { + if ((e.target as HTMLElement).closest(".star.my-star")) { + const targetStar = (e.target as HTMLElement).closest(".star.my-star"); + const targetStarIndex = this.stars.indexOf(targetStar as HTMLElement); + this.currentScore = (targetStarIndex + 1) * 2; + this.updateRatingUI(); + + this.storage.setRating(this.movieId, this.currentScore); + } + } + + private updateRatingUI() { + const stars = this.stars; + const starFilled = "./images/star_filled.png"; + const starEmpty = "./images/star_empty.png"; + + let displayScore = this.currentScore; + if (this.hoverScore > 0) { + displayScore = this.hoverScore; + } + const starIndex = displayScore / 2 - 1; + + const scoreContainer = this.container.querySelector( + ".my-score", + ) as HTMLElement; + const scoreLabel = this.container.querySelector( + ".my-score-label", + ) as HTMLElement; + const rateValue = this.container.querySelector( + ".my-rate-value", + ) as HTMLElement; + + const scoreLabelObject: Record = { + 2: "최악이예요", + 4: "별로예요", + 6: "보통이에요", + 8: "재미있어요", + 10: "명작이에요", + }; + + // 0점일 때는 텍스트 영역 숨기기 + if (displayScore === 0) { + if (scoreContainer) scoreContainer.style.display = "none"; + } else { + if (scoreContainer) scoreContainer.style.display = "flex"; + + if (scoreLabel) + scoreLabel.textContent = `${scoreLabelObject[displayScore]}`; + if (rateValue) rateValue.textContent = `(${displayScore}/10)`; + } + + // 별 이미지 바꿔주기 + stars.forEach((star, index) => { + const starElement = star as HTMLImageElement; + if (index <= starIndex) { + starElement.src = starFilled; + } else { + starElement.src = starEmpty; + } + }); + } + + public getScore() { + return this.currentScore; + } +} + +export default StarRating; diff --git a/src/movieAPIResponse.ts b/src/movieAPIResponse.ts index b72857510..919384e6e 100644 --- a/src/movieAPIResponse.ts +++ b/src/movieAPIResponse.ts @@ -1,10 +1,11 @@ import type { MovieResponse } from "../types/MovieResponse"; +import type { MovieDetail } from "../types/MovieDetail"; export const fetchMovies = async ( moviePageCount: number, ): Promise => { const response = await fetch( - `https://api.themoviedb.org/3/movie/popular?language=en-US&page=${moviePageCount}`, + `https://api.themoviedb.org/3/movie/popular?language=ko-KR&page=${moviePageCount}`, { method: "GET", headers: { @@ -26,7 +27,7 @@ export const fetchSearchedMovies = async ( searchPageCount: number, ): Promise => { const response = await fetch( - `https://api.themoviedb.org/3/search/movie?query=${searchKeyword}&page=${searchPageCount}`, + `https://api.themoviedb.org/3/search/movie?language=ko-KR&query=${searchKeyword}&page=${searchPageCount}`, { method: "GET", headers: { @@ -42,3 +43,24 @@ export const fetchSearchedMovies = async ( const data: MovieResponse = await response.json(); return data; }; + +export const fetchMovieDetail = async ( + movieId: number, +): Promise => { + const response = await fetch( + `https://api.themoviedb.org/3/movie/${movieId}?language=ko-KR`, + { + method: "GET", + headers: { + accept: "application/json", + Authorization: `Bearer ${import.meta.env.VITE_TMDB_API_TOKEN}`, + }, + }, + ); + if (!response.ok) { + throw new Error("FAILED TO FETCH MOVIE DETAIL"); + } + + const data: MovieDetail = await response.json(); + return data; +}; diff --git a/src/movieDetailRenderer.ts b/src/movieDetailRenderer.ts new file mode 100644 index 000000000..27d973135 --- /dev/null +++ b/src/movieDetailRenderer.ts @@ -0,0 +1,96 @@ +import { fetchMovieDetail } from "./movieAPIResponse"; +import type { MovieDetail } from "../types/MovieDetail"; +import StarRating from "./StarRating.ts"; +import { LocalRatingStorage } from "./storage/LocalRatingStorage"; + +const posterBaseURL = "https://image.tmdb.org/t/p/original/"; +const base = import.meta.env.BASE_URL; + +const createMovieDetailItem = ( + movieDetailData: MovieDetail, +): HTMLDivElement => { + const posterSrc = `${posterBaseURL}${movieDetailData.poster_path}`; + + // 출시년도 파싱 + const releaseDate = movieDetailData.release_date.split("-")[0]; + + // 장르 파싱 + const parseGenre = (movieDetailData: MovieDetail) => { + return movieDetailData.genres.map((genre) => genre.name).join(", "); + }; + + const modalDiv = document.createElement("div"); + modalDiv.classList.add("modal-container"); + + modalDiv.insertAdjacentHTML( + "beforeend", + /*html*/ ` + + + `, + ); + + const img = modalDiv.querySelector(".modal-image img")!; + img.addEventListener( + "error", + () => { + img.src = `${base}images/no_image.png`; + }, + { once: true }, + ); + + img.src = posterSrc; + + return modalDiv; +}; + +export const renderMovieDetail = async (movieId: number) => { + try { + const movieDetailData: MovieDetail = await fetchMovieDetail(movieId); + + const modal = document.querySelector(".modal"); + modal?.appendChild(createMovieDetailItem(movieDetailData)); + + const rateContainer = modal?.querySelector(".my-rate") as HTMLElement; + if (rateContainer) { + new StarRating(rateContainer, movieId, new LocalRatingStorage()); + } + } catch (error) { + alert( + "영화 세부정보를 불러오는 데 실패했습니다. 잠시 후 다시 시도해 주세요.", + ); + return 0; + } +}; diff --git a/src/movieRenderer.ts b/src/movieRenderer.ts index 1aa31fc89..55a0802c7 100644 --- a/src/movieRenderer.ts +++ b/src/movieRenderer.ts @@ -13,7 +13,7 @@ const createMovieItem = (movie: Movie): HTMLLIElement => { li.insertAdjacentHTML( "beforeend", /*html*/ ` -
  • +
    영화 포스터 사진 @@ -26,7 +26,7 @@ const createMovieItem = (movie: Movie): HTMLLIElement => { ${movie.title}
    -
  • `, + `, ); const img = li.querySelector(".thumbnail")!; @@ -92,7 +92,7 @@ export const renderBanner = async (fristMovieData: Movie) => { ${mostPopularMovie.vote_average}
    ${mostPopularMovie.title}
    - + `; banner?.insertAdjacentHTML("beforeend", mostPopularMovieBanner); diff --git a/src/storage/LocalRatingStorage.ts b/src/storage/LocalRatingStorage.ts new file mode 100644 index 000000000..9d7c8d638 --- /dev/null +++ b/src/storage/LocalRatingStorage.ts @@ -0,0 +1,16 @@ +import { RatingStorage } from "./RatingStorage"; + +export class LocalRatingStorage implements RatingStorage { + private readonly KEY = "movieRatings"; + + getRating(movieId: number): number { + const ratings = JSON.parse(localStorage.getItem(this.KEY) || "{}"); + return ratings[movieId] || 0; + } + + setRating(movieId: number, score: number): void { + const ratings = JSON.parse(localStorage.getItem(this.KEY) || "{}"); + ratings[movieId] = score; + localStorage.setItem(this.KEY, JSON.stringify(ratings)); + } +} diff --git a/src/storage/RatingStorage.ts b/src/storage/RatingStorage.ts new file mode 100644 index 000000000..1e34313a1 --- /dev/null +++ b/src/storage/RatingStorage.ts @@ -0,0 +1,4 @@ +export interface RatingStorage { + getRating(movieId: number): number; + setRating(movieId: number, score: number): void; +} diff --git a/src/utils/scrollObserver.ts b/src/utils/scrollObserver.ts new file mode 100644 index 000000000..597af2cb3 --- /dev/null +++ b/src/utils/scrollObserver.ts @@ -0,0 +1,16 @@ +export const createScrollObserver = ( + targetElement: HTMLElement, + onIntersect: () => void, +) => { + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + onIntersect(); + } + }); + }); + + observer.observe(targetElement); + + return () => observer.disconnect(); +}; diff --git a/templates/index.html b/templates/index.html index a4cf088d4..86c7864e0 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4,7 +4,7 @@ -영화 리뷰 + 영화 리뷰 @@ -30,44 +30,14 @@

    -

    지금 인기 있는 영화

    +
    -
    @@ -75,6 +45,15 @@

    지금 인기 있는 영화

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

    + + diff --git a/templates/modal.html b/templates/modal.html index 42e6388ca..9f81accca 100644 --- a/templates/modal.html +++ b/templates/modal.html @@ -1,522 +1,429 @@ - - - - - - - - - 영화 리뷰 - - -
    -
    -
    - -
    -

    - MovieList -

    -
    -
    - - 9.5 -
    -
    인사이드 아웃2
    - + + + + + + + + + + 영화 리뷰 + + + +
    +
    +
    + +
    +

    + MovieList +

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

    • -
    • 인기순

    • -
    • 평점순

    • -
    • 상영 예정

    • -
    -
    -
    -

    지금 인기 있는 영화

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

      - 7.7 -

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

      상영 중

      +
    • +
    • +

      인기순

      +
    • +
    • +

      평점순

      +
    • +
    • +

      상영 예정

      +
    • +
    +
    +
    +

    지금 인기 있는 영화

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

      - 7.7 -

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

      + 7.7 +

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

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

    -

    -
    +
    + + + + -