diff --git a/index.html b/index.html index 066a3e3b5..10a1656a4 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,7 @@ + 영화 리뷰 diff --git a/src/utils/api.ts b/src/api/api.ts similarity index 100% rename from src/utils/api.ts rename to src/api/api.ts diff --git a/src/api/movieApi.ts b/src/api/movieApi.ts new file mode 100644 index 000000000..aa6061b8c --- /dev/null +++ b/src/api/movieApi.ts @@ -0,0 +1,20 @@ +import { apiRequest } from "./api"; +import { MovieResponse, MovieDetail } from "../types/api"; + +export const fetchPopularMovies = (page: number): Promise => + apiRequest({ + url: `/movie/popular?language=ko-KR&page=${page}`, + }); + +export const fetchSearchMovies = ( + query: string, + page: number, +): Promise => + apiRequest({ + url: `/search/movie?language=ko-KR&query=${encodeURIComponent(query)}&page=${page}`, + }); + +export const fetchMovieDetail = (id: number): Promise => + apiRequest({ + url: `/movie/${id}?language=ko-KR`, + }); diff --git a/src/components/modal/modal.html b/src/components/modal/modal.html deleted file mode 100644 index 42e6388ca..000000000 --- a/src/components/modal/modal.html +++ /dev/null @@ -1,522 +0,0 @@ - - - - - - - - - - - 영화 리뷰 - - -
-
-
- -
-

- MovieList -

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

  • -
  • 인기순

  • -
  • 평점순

  • -
  • 상영 예정

  • -
-
-
-

지금 인기 있는 영화

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

    - 7.7 -

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

    - 7.7 -

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

    - 7.7 -

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

    - 7.7 -

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

    - 7.7 -

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

    - 7.7 -

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

    - 7.7 -

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

    - 7.7 -

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

    - 7.7 -

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

    - 7.7 -

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

    - 7.7 -

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

    - 7.7 -

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

    - 7.7 -

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

    - 7.7 -

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

    - 7.7 -

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

    - 7.7 -

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

    - 7.7 -

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

    - 7.7 -

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

    - 7.7 -

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

    - 7.7 -

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

    - 7.7 -

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

    - 7.7 -

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

    - 7.7 -

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

    - 7.7 -

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

    - 7.7 -

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

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

-

-
-
- - - - - - diff --git a/src/components/modal/modal.ts b/src/components/modal/modal.ts new file mode 100644 index 000000000..fe95a2b58 --- /dev/null +++ b/src/components/modal/modal.ts @@ -0,0 +1,207 @@ +import starFilledSrc from "../../images/star_filled.png"; +import starEmptySrc from "../../images/star_empty.png"; +import closeButtonSrc from "../../images/modal_button_close.png"; +import { IMAGE_BASE_URL, RATING_LABELS } from "../../utils/constants.ts"; +import { RatingRepository } from "../../types/ratingRepository.ts"; + +export interface ModalMovieData { + id: number; + title: string; + posterPath: string; + releaseYear: string; + genres: string[]; + rating: number; + overview: string; +} + +export class Modal { + private background: HTMLElement; + private ratingRepo: RatingRepository; + + constructor(ratingRepo: RatingRepository) { + this.ratingRepo = ratingRepo; + this.background = this.createBackground(); + document.body.appendChild(this.background); + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") this.close(); + }); + } + + private createBackground(): HTMLElement { + const background = document.createElement("div"); + background.className = "modal-background"; + + const modal = document.createElement("div"); + modal.className = "modal"; + + const closeBtn = document.createElement("button"); + closeBtn.className = "close-modal"; + closeBtn.setAttribute("aria-label", "모달 닫기"); + const closeImg = document.createElement("img"); + closeImg.src = closeButtonSrc; + closeImg.alt = "닫기"; + closeBtn.appendChild(closeImg); + closeBtn.addEventListener("click", () => this.close()); + + const container = document.createElement("div"); + container.className = "modal-container"; + + const imageDiv = document.createElement("div"); + imageDiv.className = "modal-image"; + const posterImg = document.createElement("img"); + posterImg.id = "modal-poster"; + imageDiv.appendChild(posterImg); + + const descDiv = document.createElement("div"); + descDiv.className = "modal-description"; + descDiv.id = "modal-desc"; + + container.append(imageDiv, descDiv); + modal.append(closeBtn, container); + background.appendChild(modal); + + background.addEventListener("click", (e) => { + if (e.target === background) this.close(); + }); + + return background; + } + + open(data: ModalMovieData): void { + this.renderContent(data); + this.background.classList.add("active"); + document.body.classList.add("modal-open"); + } + + close(): void { + this.background.classList.remove("active"); + document.body.classList.remove("modal-open"); + } + + private renderContent(data: ModalMovieData): void { + const poster = + this.background.querySelector("#modal-poster")!; + poster.src = `${IMAGE_BASE_URL}/w500${data.posterPath}`; + poster.alt = data.title; + + const desc = this.background.querySelector("#modal-desc")!; + desc.innerHTML = ""; + + // 제목 + const title = document.createElement("h2"); + title.textContent = data.title; + + // 개봉연도 · 장르 + const category = document.createElement("p"); + category.className = "category"; + category.textContent = `${data.releaseYear} · ${data.genres.join(", ")}`; + + // 평균 평점 + const rateP = document.createElement("p"); + rateP.className = "rate"; + const rateLabel = document.createElement("span"); + rateLabel.textContent = "평점 "; + const starImg = document.createElement("img"); + starImg.src = starFilledSrc; + starImg.className = "star"; + const rateSpan = document.createElement("span"); + rateSpan.textContent = data.rating.toFixed(1); + rateP.append(rateLabel, starImg, rateSpan); + + // 내 별점 + const myRatingSection = this.createMyRatingSection(data.id); + + const hr = document.createElement("hr"); + + // 줄거리 + const overviewTitle = document.createElement("strong"); + overviewTitle.textContent = "줄거리"; + + const detailP = document.createElement("p"); + detailP.className = "detail"; + detailP.textContent = data.overview || "줄거리 정보가 없습니다."; + + desc.append( + title, + category, + rateP, + myRatingSection, + hr, + overviewTitle, + detailP, + ); + } + + private createMyRatingSection(movieId: number): HTMLElement { + const section = document.createElement("div"); + section.className = "my-rating"; + + const label = document.createElement("span"); + label.className = "my-rating-label"; + label.textContent = "내 별점"; + + const starsDiv = document.createElement("div"); + starsDiv.className = "my-rating-stars"; + + const ratingText = document.createElement("span"); + ratingText.className = "my-rating-text"; + + const savedRating = this.ratingRepo.getRating(movieId); + this.renderStars(starsDiv, ratingText, movieId, savedRating ?? 0); + + section.append(label, starsDiv, ratingText); + return section; + } + + private renderStars( + container: HTMLElement, + ratingText: HTMLElement, + movieId: number, + currentRating: number, + ): void { + container.innerHTML = ""; + + for (let i = 1; i <= 5; i++) { + const score = i * 2; + const btn = document.createElement("button"); + btn.className = "star-btn"; + btn.setAttribute("aria-label", `${score}점`); + + const img = document.createElement("img"); + img.src = score <= currentRating ? starFilledSrc : starEmptySrc; + img.alt = score <= currentRating ? "full-star" : "empty-star"; + btn.appendChild(img); + + btn.addEventListener("mouseenter", () => { + this.highlightStars(container, i); + }); + + btn.addEventListener("mouseleave", () => { + const saved = this.ratingRepo.getRating(movieId) ?? 0; + this.renderStars(container, ratingText, movieId, saved); + }); + + btn.addEventListener("click", () => { + this.ratingRepo.setRating(movieId, score); + this.renderStars(container, ratingText, movieId, score); + ratingText.textContent = `${RATING_LABELS[score]} (${score}/10)`; + }); + + container.appendChild(btn); + } + + ratingText.textContent = + currentRating > 0 + ? `${RATING_LABELS[currentRating]} (${currentRating}/10)` + : ""; + } + + private highlightStars(container: HTMLElement, upToIndex: number): void { + const buttons = container.querySelectorAll(".star-btn"); + buttons.forEach((btn, idx) => { + const img = btn.querySelector("img")!; + img.src = idx < upToIndex ? starFilledSrc : starEmptySrc; + }); + } +} diff --git a/src/components/movie.ts b/src/components/movie.ts index 0ae7b9eac..c93465a93 100644 --- a/src/components/movie.ts +++ b/src/components/movie.ts @@ -2,14 +2,17 @@ import { Movie } from "../types/api"; import starIconSrc from "../images/star_empty.png"; import { IMAGE_BASE_URL } from "../utils/constants"; -export function createMovieList(movies: Movie[]): HTMLElement { +export function createMovieList( + movies: Movie[], + onMovieClick?: (id: number) => void, +): HTMLElement { const section = document.createElement("section"); const ul = document.createElement("ul"); ul.className = "thumbnail-list"; movies.forEach((movie) => { - const card = createMovieCard(movie); + const card = createMovieCard(movie, onMovieClick); ul.appendChild(card); }); @@ -17,11 +20,11 @@ export function createMovieList(movies: Movie[]): HTMLElement { return section; } -export function createMovieCard({ - title, - poster_path: posterImg, - vote_average: rating, -}: Movie): HTMLLIElement { +export function createMovieCard( + movie: Movie, + onMovieClick?: (id: number) => void, +): HTMLLIElement { + const { title, poster_path: posterImg, vote_average: rating, id } = movie; const li = document.createElement("li"); const item = document.createElement("div"); @@ -54,5 +57,10 @@ export function createMovieCard({ item.append(thumbnail, itemDesc); li.appendChild(item); + if (onMovieClick) { + li.style.cursor = "pointer"; + li.addEventListener("click", () => onMovieClick(id)); + } + return li; } diff --git a/src/features/infiniteScroll.ts b/src/features/infiniteScroll.ts new file mode 100644 index 000000000..d8086693b --- /dev/null +++ b/src/features/infiniteScroll.ts @@ -0,0 +1,24 @@ +export function setupInfiniteScroll( + container: Element, + onLoadMore: () => Promise, +): () => void { + const trigger = document.createElement("div"); + container.appendChild(trigger); + let isLoading = false; + + const observer = new IntersectionObserver(async ([entry]) => { + if (!entry.isIntersecting || isLoading) return; + isLoading = true; + await onLoadMore(); + + container.appendChild(trigger); + isLoading = false; + }); + + observer.observe(trigger); + + return () => { + observer.disconnect(); + trigger.remove(); + }; +} diff --git a/src/features/movieRenderer.ts b/src/features/movieRenderer.ts index f190e6ea7..d4e1eb272 100644 --- a/src/features/movieRenderer.ts +++ b/src/features/movieRenderer.ts @@ -1,5 +1,4 @@ -import { apiRequest } from "../utils/api"; -import { MovieResponse } from "../types/api"; +import { fetchPopularMovies, fetchSearchMovies } from "../api/movieApi"; import { createMovieList } from "../components/movie"; export const hideLoadMoreButton = (loadMoreBtnEl: HTMLButtonElement) => { @@ -7,24 +6,23 @@ export const hideLoadMoreButton = (loadMoreBtnEl: HTMLButtonElement) => { }; export const handleLoadMoreButton = async ( - url: string, + page: number, loadMoreBtnEl: HTMLButtonElement, mainEl: Element, skeletonEls: HTMLElement, + onMovieClick: (id: number) => void, + query?: string, ) => { loadMoreBtnEl.disabled = true; - mainEl.append(skeletonEls, loadMoreBtnEl); - const data = await apiRequest({ - url: url, - method: "GET", - }); + const data = query + ? await fetchSearchMovies(query, page) + : await fetchPopularMovies(page); if (data) { loadMoreBtnEl.disabled = false; - - const newMovieList = createMovieList(data.results); - skeletonEls.replaceWith(newMovieList); // 스켈레톤 제거 -> 새로운 영화 목록 렌더링 + const newMovieList = createMovieList(data.results, onMovieClick); + skeletonEls.replaceWith(newMovieList); } }; diff --git a/src/features/popular.ts b/src/features/popular.ts index 9dd305c52..111a9efbc 100644 --- a/src/features/popular.ts +++ b/src/features/popular.ts @@ -1,33 +1,31 @@ -import { apiRequest } from "../utils/api"; -import { MovieResponse } from "../types/api"; +import { fetchPopularMovies } from "../api/movieApi"; import { createMovieList } from "../components/movie"; -import { hideLoadMoreButton, handleLoadMoreButton } from "./movieRenderer"; +import { createSkeleton } from "../components/skeleton"; +import { setupInfiniteScroll } from "./infiniteScroll"; export const renderPopularMovieList = async ( - loadMoreBtnEl: HTMLButtonElement, mainEl: Element, skeletonEls: HTMLElement, -) => { + onMovieClick: (id: number) => void, +): Promise<() => void> => { let page = 1; - const data = await apiRequest({ - url: `/movie/popular?language=ko-KR&page=${page}`, - method: "GET", - }); - const movieList = createMovieList(data.results); - skeletonEls.replaceWith(movieList, loadMoreBtnEl); // 스켈레톤 제거 -> 영화 목록 + 더 보기 버튼 렌더링 + const data = await fetchPopularMovies(page); + skeletonEls.replaceWith(createMovieList(data.results, onMovieClick)); - // 더 이상 불러올 페이지가 없는 경우 - if (data.total_pages === page) hideLoadMoreButton(loadMoreBtnEl); + if (data.total_pages <= page) return () => {}; - loadMoreBtnEl.onclick = () => { + let stop = () => {}; + stop = setupInfiniteScroll(mainEl, async () => { page++; + const skeleton = createSkeleton(); + mainEl.appendChild(skeleton); + + const nextData = await fetchPopularMovies(page); + skeleton.replaceWith(createMovieList(nextData.results, onMovieClick)); + + if (nextData.total_pages <= page) stop(); + }); - handleLoadMoreButton( - `/movie/popular?language=ko-KR&page=${page}`, - loadMoreBtnEl, - mainEl, - skeletonEls, - ); - }; + return stop; }; diff --git a/src/features/search.ts b/src/features/search.ts index fd26d06b2..19c971ea1 100644 --- a/src/features/search.ts +++ b/src/features/search.ts @@ -1,86 +1,56 @@ -import { apiRequest } from "../utils/api"; -import { MovieResponse } from "../types/api"; +import { fetchSearchMovies } from "../api/movieApi"; import { createMovieList } from "../components/movie"; -import { hideLoadMoreButton, handleLoadMoreButton } from "./movieRenderer"; +import { createSkeleton } from "../components/skeleton"; +import { setupInfiniteScroll } from "./infiniteScroll"; -export const handleSearch = ( - input: HTMLInputElement, - loadMoreBtnEl: HTMLButtonElement, +export const handleSearch = async ( + query: string, mainEl: Element, titleEl: Element, - skeletonEls: HTMLElement, -) => { - return async (event: Event) => { - event.preventDefault(); - let page = 1; - - const query = input.value.trim(); - if (!query) return; - - updateSearchUrl(query); - - const data = await apiRequest({ - url: `/search/movie?language=ko-KR&query=${query}&page=${page}`, - method: "GET", - }); - - renderSearchResult( - data, - mainEl, - titleEl, - query, - loadMoreBtnEl, - skeletonEls, - ); - }; -}; - -// 검색어를 URL에 반영하는 함수 -const updateSearchUrl = (query: string) => { - const params = new URLSearchParams(); - - params.set("query", query); - history.pushState({}, "", `/search?${params.toString()}`); -}; - -// 검색 결과를 렌더링하는 함수 -const renderSearchResult = ( - data: MovieResponse, - mainEl: Element, - titleEl: Element, - query: string, - loadMoreBtnEl: HTMLButtonElement, - skeletonEls: HTMLElement, -) => { - let page = 1; + onMovieClick: (id: number) => void, +): Promise<() => void> => { + updateSearchUrl(query); titleEl.textContent = `"${query}" 검색 결과`; mainEl.innerHTML = ""; mainEl.appendChild(titleEl); - // 더 이상 불러올 페이지가 없는 경우 - if (data.total_pages === page) hideLoadMoreButton(loadMoreBtnEl); + const skeleton = createSkeleton(); + mainEl.appendChild(skeleton); + + let page = 1; + const data = await fetchSearchMovies(query, page); + skeleton.remove(); if (data.results.length === 0) { - const noSearchResultEl = document.createElement("p"); - noSearchResultEl.textContent = "검색 결과가 없습니다."; - noSearchResultEl.className = "no-search-result"; - mainEl.appendChild(noSearchResultEl); - return; + const noResultEl = document.createElement("p"); + noResultEl.className = "no-search-result"; + noResultEl.textContent = "검색 결과가 없습니다."; + mainEl.appendChild(noResultEl); + return () => {}; } - if (data.results.length > 0) { - const searchResult = createMovieList(data.results); - mainEl.append(searchResult, loadMoreBtnEl); - } + mainEl.appendChild(createMovieList(data.results, onMovieClick)); + + if (data.total_pages <= page) return () => {}; - loadMoreBtnEl.onclick = () => { + let stop = () => {}; + stop = setupInfiniteScroll(mainEl, async () => { page++; - handleLoadMoreButton( - `/search/movie?language=ko-KR&query=${query}&page=${page}`, - loadMoreBtnEl, - mainEl, - skeletonEls, - ); - }; + const moreSkeleton = createSkeleton(); + mainEl.appendChild(moreSkeleton); + + const nextData = await fetchSearchMovies(query, page); + moreSkeleton.replaceWith(createMovieList(nextData.results, onMovieClick)); + + if (nextData.total_pages <= page) stop(); + }); + + return stop; +}; + +const updateSearchUrl = (query: string) => { + const params = new URLSearchParams(); + params.set("query", query); + history.pushState({}, "", `/search?${params.toString()}`); }; diff --git a/src/main.ts b/src/main.ts index bc66531f5..a96bb5a63 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,10 +1,13 @@ import { createSearchForm } from "./components/search-form"; import { createHero } from "./components/hero"; -import { createButton } from "./components/button"; import { createSkeleton } from "./components/skeleton"; +import { Modal } from "./components/modal/modal"; import { renderPopularMovieList } from "./features/popular"; import { handleSearch } from "./features/search"; +import { fetchMovieDetail } from "./api/movieApi"; import { IMAGE_BASE_URL } from "./utils/constants"; +import { RatingRepository } from "./types/ratingRepository"; +import { LocalStorageRatingRepository } from "./repositories/localStorageRatingRepository"; addEventListener("load", async () => { const headerEl = document.querySelector("header")!; @@ -12,6 +15,27 @@ addEventListener("load", async () => { const mainEl = document.querySelector("#main")!; const titleEl = document.querySelector(".main-title")!; + const ratingRepo: RatingRepository = new LocalStorageRatingRepository(); + const modal = new Modal(ratingRepo); + + // 카드 클릭 이벤트 핸들러 + const onMovieClick = async (id: number) => { + try { + const detail = await fetchMovieDetail(id); + modal.open({ + id: detail.id, + title: detail.title, + posterPath: detail.poster_path, + releaseYear: detail.release_date.slice(0, 4), + genres: detail.genres.map((g) => g.name), + rating: detail.vote_average, + overview: detail.overview, + }); + } catch (error) { + alert(`영화 상세 정보를 불러오는 데 실패했습니다. ${error}`); + } + }; + // 히어로 배너 렌더링 const hero = createHero({ backgroundImageUrl: `${IMAGE_BASE_URL}/w1920_and_h800_multi_faces/stKGOm8UyhuLPR9sZLjs5AkmncA.jpg`, @@ -20,19 +44,29 @@ addEventListener("load", async () => { }); heroEl.appendChild(hero); - // 검색 폼 렌더링 const { formWrapper, form, input } = createSearchForm(); headerEl.appendChild(formWrapper); - const loadMoreBtnEl = createButton("more", "더 보기"); const skeletonEls = createSkeleton(); + mainEl.appendChild(skeletonEls); - mainEl.appendChild(skeletonEls); // 초기 로딩 시 스켈레톤 렌더링 - renderPopularMovieList(loadMoreBtnEl, mainEl, skeletonEls); // 인기 영화 목록 렌더링 - - // 검색 폼 제출 이벤트 핸들러 등록 - form.addEventListener( - "submit", - handleSearch(input, loadMoreBtnEl, mainEl, titleEl, skeletonEls), + let stopInfiniteScroll = await renderPopularMovieList( + mainEl, + skeletonEls, + onMovieClick, ); + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + const query = input.value.trim(); + if (!query) return; + + stopInfiniteScroll(); + stopInfiniteScroll = await handleSearch( + query, + mainEl, + titleEl, + onMovieClick, + ); + }); }); diff --git a/src/repositories/localStorageRatingRepository.ts b/src/repositories/localStorageRatingRepository.ts new file mode 100644 index 000000000..d26e11c0d --- /dev/null +++ b/src/repositories/localStorageRatingRepository.ts @@ -0,0 +1,19 @@ +import { STORAGE_KEY } from "../utils/constants"; +import { RatingRepository } from "../types/ratingRepository"; + +export class LocalStorageRatingRepository implements RatingRepository { + private getAll(): Record { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) : {}; + } + + getRating(movieId: number): number | null { + return this.getAll()[movieId] ?? null; + } + + setRating(movieId: number, rating: number): void { + const all = this.getAll(); + all[movieId] = rating; + localStorage.setItem(STORAGE_KEY, JSON.stringify(all)); + } +} diff --git a/src/styles/main.css b/src/styles/main.css index 42b252dab..36582c421 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -21,7 +21,6 @@ body { align-items: center; } -#wrap, section { display: flex; flex-direction: column; @@ -45,6 +44,11 @@ main#main { } } +main#main > section, +main#main > ul { + width: 100%; +} + .hidden { display: none; } @@ -73,17 +77,6 @@ button.full-width { font-weight: 600; } -#wrap { - min-width: 1440px; - background-color: var(--color-bluegray-100); -} - -#wrap h2 { - font-size: 1.4rem; - font-weight: bold; - margin-bottom: 32px; -} - .container { max-width: 1280px; margin: 0 auto; diff --git a/src/styles/modal.css b/src/styles/modal.css index 240a7a37c..17451bc81 100644 --- a/src/styles/modal.css +++ b/src/styles/modal.css @@ -1,6 +1,5 @@ @import "./colors.css"; -/* modal.css */ body.modal-open { overflow: hidden; } @@ -11,13 +10,13 @@ body.modal-open { left: 0; width: 100%; height: 100%; - background-color: rgba(0, 0, 0, 0.5); /* 반투명 배경을 위해 설정 */ - backdrop-filter: blur(10px); /* 블러 효과 적용 */ + background-color: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(10px); display: flex; justify-content: center; align-items: center; z-index: 10; - visibility: hidden; /* 모달이 기본적으로 보이지 않도록 설정 */ + visibility: hidden; opacity: 0; transition: opacity 0.3s ease, @@ -36,7 +35,9 @@ body.modal-open { color: white; z-index: 2; position: relative; - width: 1000px; + width: min(1000px, 90vw); + max-height: 90vh; + overflow-y: auto; } .close-modal { @@ -57,7 +58,6 @@ body.modal-open { } .modal-image img { - width: 380px; border-radius: 16px; } @@ -86,3 +86,89 @@ body.modal-open { max-height: 430px; overflow-y: auto; } + +/* 내 별점 */ +.my-rating { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.my-rating-label { + font-weight: bold; + white-space: nowrap; +} + +.my-rating-stars { + display: flex; + gap: 4px; +} + +.star-btn { + background: none; + border: none; + padding: 0; + cursor: pointer; + display: flex; + align-items: center; +} + +.star-btn img { + width: 24px; + height: 24px; + transition: transform 0.1s ease; +} + +.star-btn:hover img { + transform: scale(1.15); +} + +.my-rating-text { + font-size: 0.9rem; + color: #ccc; + white-space: nowrap; +} + +/* 바텀 시트 */ +@media (max-width: 800px) { + .modal-background { + align-items: flex-end; + } + + .modal { + width: 100%; + max-height: 80vh; + border-radius: 16px 16px 0 0; + transform: translateY(100%); + transition: transform 0.3s ease; + } + + .modal-background.active .modal { + transform: translateY(0); + } + + .modal-image img { + width: min(200px, 40%); + } +} + +/* 모바일: 포스터 위, 내용 아래로 스택 */ +@media (max-width: 390px) { + .modal-container { + flex-direction: column; + } + + .modal-image img { + width: 100%; + } + + .modal-description { + margin-left: 0; + margin-top: 16px; + } + + .detail { + max-height: none; + } +} diff --git a/src/styles/thumbnail.css b/src/styles/thumbnail.css index 378a4942c..85f263918 100644 --- a/src/styles/thumbnail.css +++ b/src/styles/thumbnail.css @@ -1,10 +1,10 @@ @import "./colors.css"; .thumbnail-list { - margin: 0 auto 56px; display: grid; - grid-template-columns: repeat(5, 200px); + grid-template-columns: repeat(auto-fill, 200px); gap: 70px; + justify-content: center; } .thumbnail { diff --git a/src/types/api.ts b/src/types/api.ts index baf694bf8..7b116eb21 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -22,4 +22,20 @@ interface MovieResponse { total_results: number; } -export type { Movie, MovieResponse }; +interface Genre { + id: number; + name: string; +} + +interface MovieDetail { + id: number; + title: string; + poster_path: string; + backdrop_path: string; + release_date: string; + genres: Genre[]; + vote_average: number; + overview: string; +} + +export type { Movie, MovieResponse, Genre, MovieDetail }; diff --git a/src/types/ratingRepository.ts b/src/types/ratingRepository.ts new file mode 100644 index 000000000..4a5108d5d --- /dev/null +++ b/src/types/ratingRepository.ts @@ -0,0 +1,4 @@ +export interface RatingRepository { + getRating(movieId: number): number | null; + setRating(movieId: number, rating: number): void; +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index c2b80d358..e4ce4997a 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,3 +1,13 @@ export const IMAGE_BASE_URL = "https://image.tmdb.org/t/p"; export const PAGE_SIZE = 20; + +export const STORAGE_KEY = "movie_ratings"; + +export const RATING_LABELS: Record = { + 2: "최악이에요", + 4: "별로예요", + 6: "보통이에요", + 8: "재미있어요", + 10: "명작이에요", +};