diff --git a/README.md b/README-step1.md similarity index 100% rename from README.md rename to README-step1.md diff --git a/README-step2.md b/README-step2.md new file mode 100644 index 000000000..e4a5682f4 --- /dev/null +++ b/README-step2.md @@ -0,0 +1,53 @@ +# javascript-movie-review + +FE 레벨1 영화 리뷰 미션 + +## 공통 요구사항 + +- [x] 영화 포스터나 제목을 클릭하면 자세한 예고편이나 줄거리 등의 정보를 보여준다. +- [x] 별점을 남길 수 있는 기능을 만든다. +- [x] 반응형 웹을 구상하여 디바이스의 너비에 따라 유동적으로 레이아웃이 조절되는 멋진 UI를 구현한다. + +## 기능 목록 + +### 1. 영화 상세정보 조회 모달 + +- [x] 영화 제목이나 포스터 클릭 시 모달을 표시한다. +- [x] 모달에서 보여줄 상세 정보 + - 영화 포스터 + - 영화 제목 + - 영화 장르 + - 평균 별점 + - 줄거리 +- [x] 모달 닫기 + - ESC 키를 누르는 경우 + - 모달 외부 배경을 클릭하는 경우 + - 닫기 버튼을 클릭하는 경우 + +### 2. 별점 매기기 + +- [x] 2점 단위로 구성 (2 / 4 / 6 / 8 / 10점) + - 2점 : 최악이예요 + - 4점 : 별로예요 + - 6점 : 보통이예요 + - 8점 : 재미있어요 + - 10점 : 명작이예요 +- [x] 별점 선택 시 localStorage에 저장한다. +- [x] 새로고침 후에도 사용자가 남긴 별점을 유지한다. + +### 3. 영화 목록 무한 스크롤 + +- [x] IntersectionObserver를 사용하여 무한 스크롤을 구현한다. +- [x] 브라우저 화면 끝에 도달하면 다음 20개 영화를 요청한다. + +### 4. 반응형 웹 + +- [x] 웹 / 모바일 환경에 따른 반응형 레이아웃을 구성한다. + +### 5. E2E 테스트 작성 + +- [x] 영화 포스터 또는 제목 클릭 시 모달 창 표시 +- [x] 닫기 버튼 클릭 시 모달 닫기 +- [x] ESC 키로 모달 닫기 +- [x] 별점 부여 후 새로고침 시 별점 유지 +- [x] 무한 스크롤 동작 diff --git a/cypress/e2e/spec.cy.ts b/cypress/e2e/spec.cy.ts index c5fb19411..7fe4bd377 100644 --- a/cypress/e2e/spec.cy.ts +++ b/cypress/e2e/spec.cy.ts @@ -2,22 +2,75 @@ describe("영화 리뷰 웹 E2E 테스트", () => { beforeEach(() => { cy.visit("localhost:5173"); cy.get(".thumbnail-list li").should("have.length.greaterThan", 0); + cy.get(".search-input").should("not.be.disabled"); }); - it("초기 진입 시 인기영화 목록이 표시된다", () => { - cy.get(".main-title").should("have.text", "지금 인기 있는 영화"); - cy.get(".btn-more").should("be.visible"); + // 1. 모달 표시 + it("영화 포스터 클릭 시 모달 창이 표시된다", () => { + cy.get(".thumbnail-list li").first().find(".thumbnail").click(); + cy.get(".modal-background").should("have.class", "active"); + cy.get(".modal-title").should("be.visible"); + }); + + it("영화 제목 클릭 시 모달 창이 표시된다", () => { + cy.get(".thumbnail-list li").first().find("strong").click(); + cy.get(".modal-background").should("have.class", "active"); + cy.get(".modal-title").should("be.visible"); + }); + + // 2. 닫기 버튼으로 모달 닫기 + it("닫기 버튼 클릭 시 모달이 닫힌다", () => { + cy.get(".thumbnail-list li").first().find(".thumbnail").click(); + cy.get(".modal-background").should("have.class", "active"); + cy.get(".close-modal").click(); + cy.get(".modal-background").should("not.have.class", "active"); + }); + + // 3. ESC 키로 모달 닫기 + it("ESC 키 입력 시 모달이 닫힌다", () => { + cy.get(".thumbnail-list li").first().find(".thumbnail").click(); + cy.get(".modal-background").should("have.class", "active"); + cy.get("body").type("{esc}"); + cy.get(".modal-background").should("not.have.class", "active"); + }); + + // 별점 테스트 + it("별점 부여 후 새로고침 시 별점이 유지된다", () => { + cy.get(".thumbnail-list li").first().find(".thumbnail").click(); + cy.get(".modal-background").should("have.class", "active"); + + cy.get(".modal-star[data-index='4']").click(); + cy.get(".rating-label").should("have.text", "재미있어요"); + cy.get(".rating-score").should("have.text", "(8/10)"); + + cy.get(".close-modal").click(); + cy.reload(); + cy.get(".thumbnail-list li").should("have.length.greaterThan", 0); + + cy.get(".thumbnail-list li").first().find(".thumbnail").click(); + cy.get(".modal-background").should("have.class", "active"); + cy.get(".rating-label").should("have.text", "재미있어요"); + cy.get(".rating-score").should("have.text", "(8/10)"); }); - it("더보기 클릭 시 영화 카드가 추가된다", () => { + // 무한 스크롤 테스트 + it("화면 끝에 도달하면 추가 영화가 로드된다", () => { + cy.intercept("GET", /movie\/popular/).as("loadMore"); + cy.get(".thumbnail-list li") .its("length") .then((before) => { - cy.get(".btn-more").click(); + cy.get(".scroll-sentinel").scrollIntoView(); + cy.wait("@loadMore", { timeout: 8000 }); cy.get(".thumbnail-list li").should("have.length.greaterThan", before); }); }); + it("초기 진입 시 인기영화 목록이 표시된다", () => { + cy.get(".main-title").should("have.text", "지금 인기 있는 영화"); + cy.get(".thumbnail-list li").should("have.length.greaterThan", 0); + }); + it("검색어 입력 시 검색 결과가 표시된다", () => { cy.get(".search-input").type("아이언맨"); cy.get(".search-form").submit(); diff --git a/index.html b/index.html index 4ad18c585..732b514f7 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,7 @@ + 영화 리뷰 @@ -20,7 +21,7 @@

지금 인기 있는 영화

- +
diff --git a/package-lock.json b/package-lock.json index 7fe41efda..3bdf902d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2333,19 +2333,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -3286,15 +3273,6 @@ "node": "*" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cachedir": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", @@ -5829,18 +5807,6 @@ "node": ">=8" } }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5982,49 +5948,6 @@ "url": "https://www.buymeacoffee.com/systeminfo" } }, - "node_modules/terser": { - "version": "5.39.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.0.tgz", - "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/terser/node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/throttleit": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.1.tgz", diff --git a/src/features/View/Header.ts b/src/features/View/Header.ts index fc1ca2e67..874605dea 100644 --- a/src/features/View/Header.ts +++ b/src/features/View/Header.ts @@ -24,7 +24,7 @@ export const Header = {
- ${movie.vote_average.toFixed(1)} + ${(movie.vote_average ?? 0).toFixed(1)}
${movie.title}
@@ -82,4 +82,4 @@ export const Header = {
`; }, -}; +}; \ No newline at end of file diff --git a/src/features/View/MovieCard.ts b/src/features/View/MovieCard.ts index f9e7f6fc6..bf326fd37 100644 --- a/src/features/View/MovieCard.ts +++ b/src/features/View/MovieCard.ts @@ -11,6 +11,8 @@ export default class MovieCard { render(): HTMLLIElement { const li = document.createElement("li"); + li.className = "movie-card"; + li.dataset.id = this.movie.id.toString(); li.innerHTML = `

- ${this.movie.vote_average.toFixed(1)} + ${(this.movie.vote_average ?? 0).toFixed(1)}

${this.movie.title}
diff --git a/src/features/View/MovieDetailModal.ts b/src/features/View/MovieDetailModal.ts new file mode 100644 index 000000000..b15a5c15d --- /dev/null +++ b/src/features/View/MovieDetailModal.ts @@ -0,0 +1,118 @@ +// 영화 포스터 , 영화 제목, 영화 장르, 별점, 줄거리 정보를 담은 영화 상세 정보 모달을 렌더링 +// MovieDetailModal.render(data); +import { MovieDetail } from "../../../types/types"; +import { THUMB_NAIL_URL } from "../../constants/constant"; +import closeImg from "../../images/modal_button_close.png"; +import starFilledImg from "../../images/star_filled.png"; +import starEmptyImg from "../../images/star_empty.png"; + +const star_score: Record = { + 2: "최악이예요", + 4: "별로예요", + 6: "보통이예요", + 8: "재미있어요", + 10: "명작이예요", +}; + +export default class MovieDetailModal { + div = document.createElement("div"); + + reset() { + this.div.innerHTML = ""; + this.div.classList.remove("active"); + } + + render(data: MovieDetail) { + // 포스터 + // 내용 + // - 제목 + // - 영화 제목 + // - 장르 + // - 평균 별점 + // - 내 별점 + // - 줄거리 + + this.div.className = "modal-background"; + this.div.innerHTML = /*html*/ ` + `; + document.body.appendChild(this.div); + this.div.classList.add("active"); + + this.div + .querySelector(".close-modal")! + .addEventListener("click", () => this.close()); + + this.#initStarRating(data.id); + } + + #initStarRating(movieId: number) { + const stars = this.div.querySelectorAll(".modal-star"); + const labelEl = this.div.querySelector(".rating-label")!; + const scoreEl = this.div.querySelector(".rating-score")!; + + const saved = localStorage.getItem(`rating-${movieId}`); + if (saved) this.#updateStars(stars, labelEl, scoreEl, Number(saved)); + + stars.forEach((star) => { + star.addEventListener("click", () => { + const index = Number(star.dataset.index); + this.#updateStars(stars, labelEl, scoreEl, index); + localStorage.setItem(`rating-${movieId}`, String(index)); + }); + }); + } + + #updateStars( + stars: NodeListOf, + labelEl: HTMLSpanElement, + scoreEl: HTMLSpanElement, + index: number, + ) { + stars.forEach((star, i) => { + if (i < index) { + star.src = starFilledImg; + } else { + star.src = starEmptyImg; + } + }); + const score = index * 2; + labelEl.textContent = star_score[score]; + scoreEl.textContent = `(${score}/10)`; + } + + close() { + this.div.classList.remove("active"); + } +} diff --git a/src/features/View/MovieList.ts b/src/features/View/MovieList.ts index 8b67b276a..9754d627f 100644 --- a/src/features/View/MovieList.ts +++ b/src/features/View/MovieList.ts @@ -17,12 +17,6 @@ export default class MovieList { if (mainTitle) mainTitle.textContent = title; } - updateMoreButton(totalPages: number, currentPage: number): void { - const moreButton = document.querySelector(".btn-more") as HTMLButtonElement; - if (!moreButton) return; - moreButton.style.display = totalPages === currentPage ? "none" : "block"; - } - showEmpty() { this.movieContainer!.innerHTML = `
@@ -44,6 +38,20 @@ export default class MovieList { } } + appendSkeletons(count: number = 20) { + for (let i = 0; i < count; i++) { + const skeleton = new MovieSkeleton().render(); + skeleton.classList.add("skeleton-item"); + this.movieList?.append(skeleton); + } + } + + removeSkeletons() { + this.movieList + ?.querySelectorAll(".skeleton-item") + .forEach((el) => el.remove()); + } + renderMovieList(movies: { results: Movie[] }) { movies.results.forEach((movie: Movie) => { this.movieList?.append(new MovieCard(movie).render()); diff --git a/src/features/eventHandler.ts b/src/features/eventHandler.ts index 58117c35e..da4821ec6 100644 --- a/src/features/eventHandler.ts +++ b/src/features/eventHandler.ts @@ -1,8 +1,24 @@ import { movieState } from "./movieState"; -import { initialRender, renderMoreMovies, renderSearchResults } from "./movieController"; +import { + initialRender, + renderMoreMovies, + renderSearchResults, + renderMovieDetailModal, + closeMovieDetailModal, +} from "./movieController"; import { Header } from "./View/Header"; export function initEvents() { + loadHeader(); + + loadSearch(); + + loadInfiniteScroll(); + + loadMovieDetailInfo(); +} + +function loadHeader() { const header = document.querySelector(".header") as HTMLElement; header.addEventListener("click", async (e) => { @@ -12,9 +28,12 @@ export function initEvents() { await initialRender(movieState.page); } }); +} - // 검색 - const submitContainer = document.querySelector(".background-container") as HTMLFormElement; +function loadSearch() { + const submitContainer = document.querySelector( + ".background-container", + ) as HTMLFormElement; submitContainer.addEventListener("submit", async (e: SubmitEvent) => { e.preventDefault(); movieState.page = 1; @@ -27,13 +46,57 @@ export function initEvents() { await renderSearchResults(movieState.page, movieState.searchQuery); }); +} + +function loadInfiniteScroll() { + const sentinel = document.querySelector(".scroll-sentinel") as HTMLElement; + if (!sentinel) return; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (entry.isIntersecting && !movieState.isLoading && movieState.hasMore) { + movieState.page += 1; + renderMoreMovies(movieState.page, movieState.searchQuery); + } + + // 마지막 페이지면 관찰 중단 + if (!movieState.hasMore) observer.disconnect(); + }, + { rootMargin: "200px" }, + ); - // 더보기 버튼 - const moreButton = document.querySelector(".btn-more") as HTMLButtonElement; - if (moreButton) { - moreButton.addEventListener("click", async () => { - movieState.page += 1; - await renderMoreMovies(movieState.page, movieState.searchQuery); + observer.observe(sentinel); +} + +// 하나의 영화 카드를 클릭했을 때, 해당 카드에서 영화의 id를 받아서 +// 그 id를 그 영화 정보를 렌더링 하는 함수로 넘겨준다. -> 렌더링 한다. +function loadMovieDetailInfo() { + const movieList = document.querySelector(".thumbnail-list") as HTMLElement; + if (movieList) { + movieList.addEventListener("click", async (e) => { + const target = e.target as HTMLElement; // 클릭된 요소 + const card = target.closest(".movie-card") as HTMLElement; // 카드 찾기 + if (card) { + const id = card.dataset.id; + if (id) await renderMovieDetailModal(Number(id)); + } }); } + + document.body.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + if ( + target.closest(".close-modal") || + target.classList.contains("modal-background") + ) { + closeMovieDetailModal(); + } + }); + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + closeMovieDetailModal(); + } + }); } diff --git a/src/features/movieController.ts b/src/features/movieController.ts index 02cc1f538..df04a781c 100644 --- a/src/features/movieController.ts +++ b/src/features/movieController.ts @@ -1,8 +1,16 @@ import { Header } from "./View/Header"; import MovieList from "./View/MovieList"; -import { getMoreMovies, getPopularMovies, getSearchMovies } from "./movieModel"; +import MovieDetailModal from "./View/MovieDetailModal.ts"; +import { + getMoreMovies, + getPopularMovies, + getSearchMovies, + getMovieDetail, +} from "./movieModel"; +import { movieState } from "./movieState"; const movieList = new MovieList(); +const movieDetailModal = new MovieDetailModal(); export async function initialRender(page: number): Promise { try { @@ -15,7 +23,7 @@ export async function initialRender(page: number): Promise { Header.render(data.results[0]); movieList.clearList(); movieList.renderMovieList(data); - movieList.updateMoreButton(data.total_pages, page); + movieState.hasMore = page < data.total_pages; } catch (error) { if (error instanceof Error) movieList.renderError(error.message); } @@ -35,12 +43,12 @@ export async function renderSearchResults( if (data.results.length === 0) { movieList.showEmpty(); + movieState.hasMore = false; } else { movieList.clearList(); movieList.renderMovieList(data); + movieState.hasMore = page < data.total_pages; } - - movieList.updateMoreButton(data.total_pages, page); } catch (error) { if (error instanceof Error) movieList.renderError(error.message); } @@ -50,11 +58,34 @@ export async function renderMoreMovies( page: number, searchQuery: string, ): Promise { + movieState.isLoading = true; + movieList.appendSkeletons(20); try { const data = await getMoreMovies(page, searchQuery); + movieList.removeSkeletons(); movieList.renderMovieList(data); - movieList.updateMoreButton(data.total_pages, page); + movieState.hasMore = page < data.total_pages; } catch (error) { + movieList.removeSkeletons(); if (error instanceof Error) movieList.renderError(error.message); + } finally { + movieState.isLoading = false; } } + +// 영화 상세 정보 API를 요청하여 영화 상세 정보 모달을 렌더링하는 함수 +export async function renderMovieDetailModal(id: number) { + try { + movieDetailModal.reset(); + // 영화 상세 정보 API 요청 + const data = await getMovieDetail(id); + // 영화 상세 정보 모달 렌더링 함수 호출 + movieDetailModal.render(data); + } catch (error) { + if (error instanceof Error) movieList.renderError(error.message); + } +} + +export function closeMovieDetailModal() { + movieDetailModal.close(); +} diff --git a/src/features/movieModel.ts b/src/features/movieModel.ts index d4c88a04e..25165d4a1 100644 --- a/src/features/movieModel.ts +++ b/src/features/movieModel.ts @@ -37,3 +37,14 @@ export async function getMoreMovies(page: number, searchQuery: string): Promise< throw new Error("영화 데이터를 불러오는 중 오류가 발생했습니다."); } } + +export async function getMovieDetail(id: Number){ + try{ + const response = await fetch(`${BASE_URL}/movie/${id}?api_key=${API_KEY}&language=ko-KR`); + if (!response.ok) throw new Error(`API 요청 실패: ${response.status}`); + return response.json(); + } + catch(error){ + throw new Error("영화 데이터를 불러오는 중 오류가 발생했습니다.") + } +} \ No newline at end of file diff --git a/src/features/movieState.ts b/src/features/movieState.ts index 41cdea2d1..89abc3821 100644 --- a/src/features/movieState.ts +++ b/src/features/movieState.ts @@ -1,8 +1,12 @@ export const movieState = { page: 1, searchQuery: "", + isLoading: false, + hasMore: true, reset() { this.page = 1; this.searchQuery = ""; + this.isLoading = false; + this.hasMore = true; }, }; diff --git a/src/styles/main.css b/src/styles/main.css index 893f972c1..b32775bf0 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -36,14 +36,14 @@ main { margin-bottom: 50px; } -@media screen and (max-width: 1297px) { +@media screen and (max-width: 1310px) { .main-title { - padding: 48px; + font-size: 36px; } } .star { - width: 24px; + width: 16px; } button { @@ -60,7 +60,6 @@ button.primary { } #wrap { - min-width: 1440px; background-color: var(--color-bluegray-100); } @@ -73,6 +72,8 @@ button.primary { .container { max-width: 1280px; margin: 0 auto; + width: 100%; + padding: 0 24px; } .search-container { @@ -94,11 +95,26 @@ button.primary { opacity: 1; } -@media screen and (max-width: 800px) { +@media screen and (max-width: 768px) { .search-container { - flex-direction: column; margin-top: 90px; - margin-left: 75px; + justify-content: center; + display: flex; + align-items: center; + padding: 0 24px; + } + + .search-form { + width: 100%; + max-width: 360px; + margin-top: 0; + } + + .main-title { + font-size: 36px; + margin-bottom: 56px; + text-align: center; + padding: 0; } } @@ -115,9 +131,10 @@ button.primary { color: var(--color-white); } -@media screen and (max-width: 1080px) { +@media screen and (min-width: 769px) and (max-width: 1080px) { .search-form { width: 325px; + top: 36px; } } @@ -151,6 +168,22 @@ button.primary { gap: 4px; } +@media screen and (max-width: 1024px) { + .background-container { + padding-left: 48px; + height: 420px; + } +} + +@media screen and (max-width: 768px) { + .background-container { + padding: 80px 24px 32px; + height: auto; + min-height: 320px; + justify-content: flex-end; + } +} + .overlay { position: absolute; top: 0; @@ -166,6 +199,8 @@ button.primary { z-index: 2; max-width: 1280px; margin: 0 auto; + width: 100%; + padding: 0 24px; } .top-rated-movie > *:not(:last-child) { @@ -180,6 +215,22 @@ h1.logo { cursor: pointer; } +@media screen and (max-width: 1080px) { + h1.logo { + left: 24px; + top: 48px; + font-size: 1.5rem; + } +} + +@media screen and (max-width: 768px) { + h1.logo { + left: 24px; + top: 20px; + font-size: 1.5rem; + } +} + .rate { display: flex; align-items: baseline; @@ -202,6 +253,18 @@ span.rate-value { font-weight: bold; } +@media screen and (max-width: 1024px) { + .title { + font-size: 2.2rem; + } +} + +@media screen and (max-width: 768px) { + .title { + font-size: 1.6rem; + } +} + footer.footer { min-height: 180px; background-color: var(--color-bluegray-80); diff --git a/src/styles/modal.css b/src/styles/modal.css index 240a7a37c..e393591a7 100644 --- a/src/styles/modal.css +++ b/src/styles/modal.css @@ -16,7 +16,7 @@ body.modal-open { display: flex; justify-content: center; align-items: center; - z-index: 10; + z-index: 1000; visibility: hidden; /* 모달이 기본적으로 보이지 않도록 설정 */ opacity: 0; transition: @@ -29,6 +29,11 @@ body.modal-open { opacity: 1; } +hr { + border: 1px solid #67788e; + width: 100%; +} + .modal { background-color: var(--color-bluegray-90); padding: 20px; @@ -36,7 +41,161 @@ body.modal-open { color: white; z-index: 2; position: relative; - width: 1000px; + width: min(1000px, calc(100vw - 32px)); + max-height: 90vh; + overflow-y: auto; +} + +.modal-container { + width: 100%; + opacity: 1; + padding-top: 40px; + padding-right: 24px; + padding-bottom: 40px; + padding-left: 24px; + gap: 24px; + border-radius: 16px; +} + +.modal-title { + font-family: DM Sans; + font-weight: 700; + font-style: Bold; + font-size: 32px; + line-height: 100%; + letter-spacing: 0%; +} + +.modal-release-date-and-genres { + font-family: DM Sans; + font-weight: 400; + font-style: Regular; + font-size: 20px; + line-height: 100%; + letter-spacing: 0%; +} + +.modal-rating-text { + font-size: 20px; + margin: 0; + margin-right: 16px; + font-family: DM Sans; + font-weight: 400; + font-style: Regular; + font-size: 20px; + line-height: 100%; + letter-spacing: 0%; + padding-top: 2px; +} + +.star-rating { + display: flex; + align-items: center; + gap: 8px; +} + +.stars-row { + display: flex; + align-items: center; + gap: 4px; +} + +.rating-text { + display: flex; + align-items: center; + gap: 4px; +} + +.modal-star { + width: 32px; + height: 32px; +} + +.rating-label { + font-family: Montserrat; + font-weight: 600; + font-style: SemiBold; + font-size: 24px; + line-height: 100%; + letter-spacing: 0.18px; + margin-left: 10px; + margin-top: 5px; +} + +.rating-score { + font-family: Montserrat; + font-weight: 600; + font-style: SemiBold; + font-size: 24px; + line-height: 100%; + letter-spacing: 0.18px; + color: #95a1b2; + margin-top: 5px; +} + +.modal-user-rating-text { + font-family: Montserrat; + font-weight: 600; + font-style: SemiBold; + font-size: 24px; + line-height: 100%; + letter-spacing: 0.18px; + margin-bottom: 24px; +} + +.modal-overview-title { + font-family: Montserrat; + font-weight: 600; + font-style: SemiBold; + font-size: 24px; + line-height: 100%; + letter-spacing: 0.18px; +} + +.modal-overview { + font-family: Montserrat; + font-weight: 300; + font-style: Light; + font-style: Bold; + font-size: 24px; + line-height: 147%; + letter-spacing: 0.18px; + max-height: 218px; + overflow-y: auto; + overflow-x: hidden; +} + +.modal-overview::-webkit-scrollbar { + width: 6px; +} + +.modal-overview::-webkit-scrollbar-track { + background: transparent; +} + +.modal-overview::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.3); + border-radius: 3px; +} + +.modal-rating { + margin: 0; + margin-left: 2px; + color: #ffc700; + font-family: Montserrat; + font-weight: 600; + font-size: 24px; + line-height: 1; + letter-spacing: 0.18px; + padding-top: 1.5px; + font-style: SemiBold; + line-height: 100%; +} + +.average-star { + width: 24px; + height: 24px; + margin-right: 2.5px; } .close-modal { @@ -50,6 +209,8 @@ body.modal-open { color: white; font-size: 20px; cursor: pointer; + width: 25px; + height: 25px; } .modal-container { @@ -61,11 +222,28 @@ body.modal-open { border-radius: 16px; } +.modal-image { + width: 400px; + height: 600px; + flex-shrink: 0; + opacity: 1; + border-radius: 16px; +} + +.average { + display: flex; + align-items: center; + line-height: 1; +} + .modal-description { + display: flex; + flex-direction: column; width: 100%; padding: 8px; margin-left: 16px; line-height: 1.6rem; + gap: 16px; } .modal-description .rate > img { @@ -86,3 +264,218 @@ body.modal-open { max-height: 430px; overflow-y: auto; } + +/* 태블릿 */ +@media screen and (min-width: 390px) and (max-width: 1024px) { + .modal-background { + align-items: flex-end; + } + + .modal { + width: 100vw; + max-height: 140vh; + border-radius: 16px 16px 0 0; + padding: 16px 24px; + overflow-y: hidden; + } + + .modal-container { + flex-direction: column; + align-items: center; + padding: 12px 0; + gap: 20px; + } + + /* 포스터: 중앙 정렬, 작게 */ + .modal-image { + width: 150px; + height: auto; + flex-shrink: 0; + } + + .modal-image img { + width: 110px; + height: auto; + border-radius: 8px; + object-fit: cover; + } + + /* 설명 영역 전체 */ + .modal-description { + width: 100%; + margin-left: 0; + padding: 0; + gap: 6px; + align-items: center; + } + + /* 제목/장르/평균 별점: 중앙 */ + .modal-title { + font-size: 28px; + text-align: center; + } + + .modal-release-date-and-genres { + font-size: 16px; + text-align: center; + } + + .average { + justify-content: center; + } + + .modal-rating { + font-size: 16px; + } + + .average-star { + width: 14px; + height: 14px; + } + + /* 내 별점 + 줄거리: 왼쪽 정렬 */ + .modal-user-rating { + width: 100%; + text-align: left; + } + + .modal-user-rating-text { + font-size: 20px; + margin-bottom: 10px; + margin-left: 2px; + } + + .modal-rating-text { + font-size: 16px; + } + + .rating-label, + .rating-score { + font-size: 20px; + margin-bottom: 2px; + } + + .modal-star { + width: 20px; + height: 20px; + } + + .modal-overview-title { + width: 100%; + font-size: 20px; + text-align: left; + } + + .modal-overview { + width: 100%; + font-size: 20px; + max-height: 80px; + overflow-y: auto; + text-align: left; + } +} + +/* 모바일 */ +@media screen and (max-width: 390px) { + .modal-background { + align-items: flex-end; + } + + .modal { + width: 100vw; + border-radius: 16px 16px 0 0; + padding: 24px; + overflow-y: hidden; + } + + .modal-container { + flex-direction: column; + align-items: center; + padding: 12px 0; + gap: 12px; + } + + /* 이미지 숨김 */ + .modal-image { + display: none; + } + + .modal-description { + width: 100%; + margin-left: 0; + padding: 0; + gap: 10px; + align-items: center; + } + + .modal-title { + font-size: 20px; + text-align: center; + padding: 0 36px; + } + + .modal-release-date-and-genres { + font-size: 16px; + text-align: center; + } + + .average { + justify-content: center; + } + + /* 내 별점: 중앙 정렬 */ + .modal-user-rating { + width: 100%; + align-items: center; + text-align: center; + flex-direction: column; + } + + .modal-user-rating-text { + font-size: 18px; + margin-bottom: 8px; + } + + .star-rating { + flex-direction: column; + align-items: center; + gap: 8px; + } + + .modal-star { + width: 28px; + height: 28px; + } + + .rating-label, + .rating-score { + font-size: 18px; + } + + .modal-rating { + font-size: 20px; + } + + .modal-rating-text { + font-size: 16px; + } + + .average-star { + width: 16px; + height: 16px; + } + + /* 줄거리 제목: 중앙, 본문: 왼쪽 */ + .modal-overview-title { + width: 100%; + font-size: 18px; + text-align: center; + } + + .modal-overview { + width: 100%; + font-size: 18px; + max-height: none; + text-align: left; + } +} diff --git a/src/styles/thumbnail.css b/src/styles/thumbnail.css index fa3b5432d..dbcbe1975 100644 --- a/src/styles/thumbnail.css +++ b/src/styles/thumbnail.css @@ -3,32 +3,47 @@ .thumbnail-list { margin: 0 auto 56px; display: grid; - grid-template-columns: repeat(5, 200px); - gap: 70px; + grid-template-columns: repeat(5, 1fr); + gap: 72px; } @media screen and (max-width: 1310px) { .thumbnail-list { grid-template-columns: repeat(4, 1fr); + gap: 53px; } } -@media screen and (max-width: 1080px) { +@media screen and (max-width: 1050px) { .thumbnail-list { grid-template-columns: repeat(3, 1fr); + gap: 70px; + margin-bottom: 40px; } } -@media screen and (max-width: 800px) { +@media screen and (max-width: 769px) { .thumbnail-list { grid-template-columns: repeat(2, 1fr); + gap: 70px; + margin-bottom: 70px; + } +} + +@media screen and (max-width: 653px) { + .thumbnail-list { + grid-template-columns: repeat(1, 1fr); + gap: 70px; } } .thumbnail { - width: 200px; - height: 300px; + width: 100%; + aspect-ratio: 2 / 3; + height: auto; border-radius: 8px; + object-fit: cover; + display: block; } .item { @@ -37,6 +52,7 @@ display: flex; flex-direction: column; gap: 10px; + width: 100%; } .item-desc { @@ -67,6 +83,60 @@ p.rate > span { } .item .star { - width: 16px; + width: 18px; + height: 18px; top: 1px; } + +/* Skeleton */ +@keyframes skeleton-shimmer { + 0% { + background-position: -400px 0; + } + 100% { + background-position: 400px 0; + } +} + +.movie-skeleton { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; +} + +.movie-skeleton__poster, +.movie-skeleton__title, +.movie-skeleton__info { + background: linear-gradient( + 90deg, + var(--color-bluegray-80) 25%, + var(--color-bluegray-90) 50%, + var(--color-bluegray-80) 75% + ); + background-size: 800px 100%; + animation: skeleton-shimmer 1.4s infinite linear; + border-radius: 4px; +} + +.movie-skeleton__poster { + width: 100%; + aspect-ratio: 2 / 3; + border-radius: 8px; +} + +.movie-skeleton__details { + display: flex; + flex-direction: column; + gap: 8px; +} + +.movie-skeleton__title { + height: 20px; + width: 80%; +} + +.movie-skeleton__info { + height: 16px; + width: 50%; +} diff --git a/types/types.ts b/types/types.ts index 6e2f484c8..2fc135e26 100644 --- a/types/types.ts +++ b/types/types.ts @@ -5,3 +5,9 @@ export interface Movie { vote_average: number; backdrop_path: string; } + +export interface MovieDetail extends Movie { + overview: string; + genres: {id: number, name : string}[]; + release_date : string; +}