diff --git a/__test__/api.test.ts b/__test__/api.test.ts index 897c10108..4e5b07d68 100644 --- a/__test__/api.test.ts +++ b/__test__/api.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { fetchPopularMovies, fetchSearchMovies } from '../src/api/fetchApi.ts'; import MOCK_DATA from './mock/page_1.json'; +import MOCK_ERROR from './mock/page_error.json'; vi.stubGlobal('fetch', vi.fn()); diff --git a/__test__/mock/item_1.json b/__test__/mock/item_1.json new file mode 100644 index 000000000..fe4405c9f --- /dev/null +++ b/__test__/mock/item_1.json @@ -0,0 +1,87 @@ +{ + "adult": false, + "backdrop_path": "/1x9e0qWonw634NhIsRdvnneeqvN.jpg", + "belongs_to_collection": null, + "budget": 0, + "genres": [ + { + "id": 10749, + "name": "로맨스" + }, + { + "id": 18, + "name": "드라마" + } + ], + "homepage": "", + "id": 1523145, + "imdb_id": "tt38190257", + "origin_country": [ + "RU" + ], + "original_language": "ru", + "original_title": "Твоё сердце будет разбито", + "overview": "", + "popularity": 821.6874, + "poster_path": "/iGpMm603GUKH2SiXB2S5m4sZ17t.jpg", + "production_companies": [ + { + "id": 71928, + "logo_path": "/7M6z2jX570P3HzNQULCTjlZcXkB.png", + "name": "All Media A Start Company", + "origin_country": "RU" + }, + { + "id": 114676, + "logo_path": "/aVsos0RDN9Ib7H8nW5Vv5kHKykz.png", + "name": "START Studio", + "origin_country": "RU" + }, + { + "id": 13850, + "logo_path": "/6KsdcnTIpQ47NHad4EtxOCkB94K.png", + "name": "Sverdlovsk Film Studio", + "origin_country": "RU" + }, + { + "id": 76049, + "logo_path": "/tam9pIV4QK1ZPwdxBgtVkK1WTYC.png", + "name": "Cinema Foundation of Russia", + "origin_country": "RU" + }, + { + "id": 42877, + "logo_path": "/15THT1W3NROsbDXPbrcmWFVkrSv.png", + "name": "Yellow, Black & White", + "origin_country": "RU" + }, + { + "id": 119813, + "logo_path": "/iyGJzcnWXZoNxYLzZu4jKzxIoFw.png", + "name": "Premier Studios", + "origin_country": "RU" + } + ], + "production_countries": [ + { + "iso_3166_1": "RU", + "name": "Russia" + } + ], + "release_date": "2026-03-26", + "revenue": 0, + "runtime": 134, + "spoken_languages": [ + { + "english_name": "Spanish", + "iso_639_1": "es", + "name": "Español" + } + ], + "status": "Released", + "tagline": "", + "title": "Твоё сердце будет разбито", + "video": false, + "vote_average": 7.045, + "vote_count": 56 +} \ No newline at end of file diff --git a/cypress/e2e/spec.cy.ts b/cypress/e2e/spec.cy.ts index f595ad1a3..ad7b60f8d 100644 --- a/cypress/e2e/spec.cy.ts +++ b/cypress/e2e/spec.cy.ts @@ -1,40 +1,85 @@ import MOCK_PAGE_1 from '../../__test__/mock/page_1.json'; import MOCK_PAGE_2 from '../../__test__/mock/page_2.json'; +import MOCK_ITEM_1 from '../../__test__/mock/item_1.json'; import MOCK_ERROR from '../../__test__/mock/page_error.json'; -describe('영화 리뷰 앱 E2E 테스트', () => { +describe('e2e 테스트', () => { beforeEach(() => { cy.intercept('GET', '**/movie/popular?*page=1*', { statusCode: 200, body: MOCK_PAGE_1, - }).as('getPopularMovies'); + }).as('getPopularMoviePage1'); cy.intercept('GET', '**/movie/popular?*page=2*', { statusCode: 200, body: MOCK_PAGE_2, - }).as('getNextPopularMovies'); + }).as('getPopularMoviePage2'); + + cy.intercept('GET', '**/movie/[0-9]*', { + statusCode: 200, + body: MOCK_ITEM_1, + }).as('getMovieDetail'); }); - context('1. 메인 페이지 (인기 영화)', () => { - it('메인 페이지에 접속하면 인기 영화 20개를 렌더링한다.', () => { - cy.visit('http://localhost:5175/'); - cy.wait('@getPopularMovies'); + context('사용자는 접속하여 홈페이지에서 영화 리스트를 볼 수 있다', () => { + it('메인 페이지의 소개 문구 확인', () => { + cy.visit('http://localhost:5173/'); + cy.wait('@getPopularMoviePage1'); + cy.get('.top-rated-movie .title').should('have.text', MOCK_PAGE_1.results[0].title); + cy.get('section h2').should('have.text', '지금 인기있는 영화'); cy.get('.thumbnail-list > li').should('have.length', 20); }); - it('더보기 버튼을 누르면 다음 페이지의 영화 20개를 추가로 렌더링한다.', () => { - cy.visit('http://localhost:5175/'); - cy.wait('@getPopularMovies'); + it('스크롤을 내리면 다음 페이지의 영화 20개를 추가로 렌더링한다.', () => { + cy.visit('http://localhost:5173/'); + cy.wait('@getPopularMoviePage1'); - cy.get('.more-button').click(); - cy.wait('@getNextPopularMovies'); + cy.scrollTo('bottom'); + cy.wait('@getPopularMoviePage2'); cy.get('.thumbnail-list > li').should('have.length', 40); }); + + it('영화 리스트 중 하나 클릭했다가 취소하고 나온다 ESC도 가능해야한다', () => { + cy.visit('http://localhost:5173/'); + cy.wait('@getPopularMoviePage1'); + + cy.get('ul li:first-child').click(); + cy.get('.modal').should('be.visible'); + + cy.get('.close-modal').click(); + cy.get('.modal').should('not.be.visible'); + + cy.get('body').type('{esc}'); + cy.get('.modal').should('not.be.visible'); + }); }); - context('2. 검색 기능', () => { + context('모달', () => { + it('사용자는 모달을 열고 평점을 6점준다. 별점은 로컬 스토리지에 저장된다', () => { + cy.visit('http://localhost:5173/'); + cy.wait('@getPopularMoviePage1'); + + cy.get('ul li:first-child').click(); + cy.get('.modal').should('be.visible'); + + cy.get('.modal-description h2').should('contain', MOCK_ITEM_1.title); + cy.get('.rate span').should('contain', MOCK_ITEM_1.vote_average.toFixed(1)); + cy.get('.star-container .submit-star-button').eq(2).click(); + + cy.get('.star-container .submit-star-button').each(($el, index) => { + const img = $el.find('img'); + if (index <= 2) { + cy.wrap(img).should('have.attr', 'alt', 'star_filled'); + } else { + cy.wrap(img).should('have.attr', 'alt', 'star_empty'); + } + }); + }); + }); + + context('검색 기능', () => { it('검색창에 검색어를 입력하고 엔터를 누르면 검색 결과 페이지로 이동한다.', () => { const encodedQuery = encodeURIComponent('해리포터'); @@ -43,27 +88,27 @@ describe('영화 리뷰 앱 E2E 테스트', () => { body: MOCK_PAGE_1, }).as('getSearchMovies'); - cy.visit('http://localhost:5175/'); + cy.visit('http://localhost:5173/'); cy.get('input[name="q"]').type('해리포터{enter}'); cy.wait('@getSearchMovies'); cy.url().should('include', `/search?query=${encodedQuery}`); - cy.get('.thumbnail-list > li').should('have.length', 20); + cy.get('.thumbnail-list li').should('have.length', 20); }); it('검색 결과가 없는 경우 "검색 결과가 없습니다" UI를 띄워준다.', () => { const emptyMockData = { page: 1, results: [], total_pages: 0 }; - const encodedEmptyQuery = encodeURIComponent('ㅇㅅㅇ'); + const encodedEmptyQuery = encodeURIComponent('궤뚫쉟헽'); cy.intercept('GET', `**/search/movie?*query=${encodedEmptyQuery}*`, { statusCode: 200, body: emptyMockData, }).as('getEmptyMovies'); - cy.visit('http://localhost:5175/'); - cy.get('input[name="q"]').type('ㅇㅅㅇ{enter}'); + cy.visit('http://localhost:5173/'); + cy.get('input[name="q"]').type('궤뚫쉟헽{enter}'); cy.wait('@getEmptyMovies'); cy.get('.nothing').should('be.visible'); @@ -71,21 +116,17 @@ describe('영화 리뷰 앱 E2E 테스트', () => { }); }); - context('3. 예외 처리 (API 에러)', () => { - it('네트워크 통신에 실패할 경우 에러 메시지를 화면에 표시한다.', () => { - cy.on('uncaught:exception', (err, runnable) => { - return false; - }); - + context('API 에러', () => { + it('에러가 발생 시 에러 컴포넌트를 불러옴', () => { cy.intercept('GET', '**/movie/popular?*', { statusCode: 500, body: MOCK_ERROR, }).as('getApiError'); - cy.visit('http://localhost:5175/'); + cy.visit('http://localhost:5173/'); cy.wait('@getApiError'); cy.get('.nothing').should('be.visible'); - cy.get('.nothing p').should('contain', 'TMDB'); + cy.get('.nothing p').should('contain', '에러'); }); }); }); diff --git a/index.html b/index.html index 56d8bb253..d1f97395a 100644 --- a/index.html +++ b/index.html @@ -9,6 +9,7 @@ + diff --git a/src/api/constant.ts b/src/api/constant.ts index 8a375b426..82b3fb02a 100644 --- a/src/api/constant.ts +++ b/src/api/constant.ts @@ -7,4 +7,5 @@ export const URL = { export const PATH = { MOVIE_POPULAR: '/movie/popular', SEARCH_MOVIE: '/search/movie', + MOVIE_DETAIL: (movie_id: number) => `/movie/${movie_id}`, } as const; diff --git a/src/api/fetchApi.ts b/src/api/fetchApi.ts index 824bff998..cdc9f6a70 100644 --- a/src/api/fetchApi.ts +++ b/src/api/fetchApi.ts @@ -1,6 +1,6 @@ import { URL, PATH } from './constant.ts'; import TMDBError from './TMDBError.ts'; -import { ResponseMovie, Request, TmdbErrorType } from './types.ts'; +import { ResponseMovie, Request, TmdbErrorType, MovieDetail } from './types.ts'; const API_KEY = import.meta.env.VITE_API_KEY; const options = { @@ -11,15 +11,14 @@ const options = { }, }; -const fetchAPI = async (req: Request): Promise => { +const fetchAPI = async (req: Request): Promise => { const url = URL.BASE + req.path; const { query, page } = req.params; - const params = new URLSearchParams({ language: 'ko-KR', page: String(page), region: 'kr' }); + const params = new URLSearchParams({ language: 'ko-KR', region: 'kr' }); if (query) params.set('query', query); - + if (page) params.set('page', String(page)); const resultUrl = url + '?' + params.toString(); - const response = await fetch(resultUrl, options); const data = await response.json(); @@ -43,3 +42,10 @@ export const fetchPopularMovies = (page: number = 1): Promise => params: { page }, }); }; + +export const fetchMovieDetails = (movie_id: number): Promise => { + return fetchAPI({ + path: PATH.MOVIE_DETAIL(movie_id), + params: {}, + }); +}; diff --git a/src/api/renderImage.ts b/src/api/renderImage.ts index 14b25b634..20585f202 100644 --- a/src/api/renderImage.ts +++ b/src/api/renderImage.ts @@ -1,9 +1,12 @@ import { URL } from './constant'; -export const getOriginalImageUrl = (src: string): string => { +const PLACEHOLDER = './images/empty.png'; +export const getOriginalImageUrl = (src: string | null): string => { + if (!src) return PLACEHOLDER; return URL.ORIGINAL_IMAGE + src; }; export const getThumbnailImageUrl = (src: string): string => { + if (!src) return PLACEHOLDER; return URL.THUMBNAIL_IMAGE + src; }; diff --git a/src/api/types.ts b/src/api/types.ts index fa9931cbc..38000e83b 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,10 +1,24 @@ export type MovieData = { + id: number; title: string; poster_path: string; backdrop_path: string; vote_average: number; }; +type Genres = { + name: string; +}; +export type MovieDetail = { + id: number; + title: string; + overview: string; + poster_path: string; + genres: Genres[]; + vote_average: string; + release_date: string; +}; + export type ResponseMovie = { results: MovieData[]; page: number; @@ -12,7 +26,7 @@ export type ResponseMovie = { }; type Params = { - page: number; + page?: number; query?: string | undefined; language?: string; region?: string; diff --git a/src/components/common/ErrorComponent.ts b/src/components/common/ErrorComponent.ts new file mode 100644 index 000000000..695bc73ad --- /dev/null +++ b/src/components/common/ErrorComponent.ts @@ -0,0 +1,13 @@ +import TMDBError from '../../api/TMDBError'; + +export const ErrorComponent = (error: Error | TMDBError) => { + const $div = document.createElement('div'); + const infoMessage = error instanceof TMDBError ? 'TMDB 에러' : '예상치못한 에러'; + $div.className = 'nothing'; + $div.innerHTML = ` + nothing +

${infoMessage}

+ `; + + return $div; +}; diff --git a/src/components/common/Rate.ts b/src/components/common/Rate.ts new file mode 100644 index 000000000..49189a89a --- /dev/null +++ b/src/components/common/Rate.ts @@ -0,0 +1,14 @@ +import { Star } from './Star'; + +export const Rate = (vote_average: string, filled?: boolean) => { + const $p = document.createElement('p'); + $p.className = 'rate'; + + const $span = document.createElement('span'); + + const roundVote = Math.round(Number(vote_average) * 10) / 10; + $span.textContent = roundVote.toFixed(1); + + $p.append(Star(filled), $span); + return $p; +}; diff --git a/src/components/common/Star.ts b/src/components/common/Star.ts index 85e4e00eb..795c5e79f 100644 --- a/src/components/common/Star.ts +++ b/src/components/common/Star.ts @@ -1,8 +1,8 @@ -export const Star = () => { +export const Star = (filled?: boolean) => { const $img = document.createElement('img'); $img.className = 'star'; - $img.src = './images/star_empty.png'; - $img.alt = 'star_empty'; + $img.src = filled ? './images/star_filled.png' : './images/star_empty.png'; + $img.alt = filled ? 'star_filled' : 'star_empty'; return $img; }; diff --git a/src/components/header/Header.ts b/src/components/header/Header.ts new file mode 100644 index 000000000..d4f2cec10 --- /dev/null +++ b/src/components/header/Header.ts @@ -0,0 +1,61 @@ +import { getOriginalImageUrl } from '../../api/renderImage.ts'; +import { MovieData } from '../../api/types.ts'; +import { $ } from '../../utils/dom.ts'; +import { Logo } from './Logo.ts'; +import { SearchForm } from './SearchForm.ts'; +import { TopRate } from './TopRate.ts'; +import { Overlay } from './Overlay.ts'; + +export default class Header { + #$element: HTMLElement; + #$background: HTMLElement; + #$banner: HTMLElement; + #$overlay: HTMLElement; + #$topRate: HTMLElement | null = null; + + #onDetail: (movie_id: number) => Promise; + + constructor(onSubmit: (query: string) => void, onDetail: (movie_id: number) => Promise) { + this.#$element = document.createElement('header'); + this.#$element.innerHTML = ` +
+ +
+ `; + + this.#$background = $(this.#$element, '.background-container'); + this.#$banner = $(this.#$element, '.banner-container'); + this.#$overlay = Overlay(); + this.#$overlay.classList.add('hidden'); + + this.#$background.prepend(this.#$overlay); + const $justLayout = document.createElement('div'); + this.#$banner.append(Logo(), SearchForm(onSubmit), $justLayout); + this.#$banner.classList.add('hidden'); + + this.#onDetail = onDetail; + } + + get $element() { + return this.#$element; + } + + showBanner(data: MovieData) { + this.#$background.style.backgroundImage = `url(${getOriginalImageUrl(data.backdrop_path)})`; + this.#$background.classList.add('top-header-container'); + + if (this.#$topRate) this.#$topRate.remove(); + this.#$topRate = TopRate(data, this.#onDetail); + this.#$background.append(this.#$topRate); + + this.#$overlay.classList.remove('hidden'); + this.#$banner.classList.remove('hidden'); + } + + hideBanner() { + this.#$overlay.classList.add('hidden'); + this.#$banner.classList.add('hidden'); + this.#$background.classList.remove('top-header-container'); + this.#$background.style.backgroundImage = ''; + } +} diff --git a/src/components/header/SearchForm.ts b/src/components/header/SearchForm.ts index ac10cf111..abe747f16 100644 --- a/src/components/header/SearchForm.ts +++ b/src/components/header/SearchForm.ts @@ -19,8 +19,11 @@ export const SearchForm = (onSubmit: (query: string) => void) => { $form.addEventListener('submit', (e) => { e.preventDefault(); - const query = $($form, '#search-input').value; + + const $input = $($form, '#search-input'); + const query = $input.value; onSubmit(query); + $input.value = query; }); return $form; }; diff --git a/src/components/header/SearchHeader.ts b/src/components/header/SearchHeader.ts deleted file mode 100644 index 73211685a..000000000 --- a/src/components/header/SearchHeader.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { $ } from '../../utils/dom.ts'; -import { Logo } from './Logo.ts'; -import { SearchForm } from './SearchForm.ts'; - -export default class SearchHeader { - #$element: HTMLElement; - - constructor(onSubmit: (query: string) => void) { - this.#$element = document.createElement('header'); - this.#$element.innerHTML = ` -
-
-
- `; - - $(this.#$element, '.top-rated-container').append(Logo(), SearchForm(onSubmit)); - } - - get $element() { - return this.#$element; - } -} diff --git a/src/components/header/TopRate.ts b/src/components/header/TopRate.ts index 8f520ed53..77b4d6ad5 100644 --- a/src/components/header/TopRate.ts +++ b/src/components/header/TopRate.ts @@ -2,20 +2,24 @@ import { MovieData } from '../../api/types.ts'; import { $ } from '../../utils/dom.ts'; import { Star } from '../common/Star.ts'; -export const TopRate = (data: MovieData): HTMLElement => { +export const TopRate = (data: MovieData, onDetail: (movie_id: number) => Promise): HTMLElement => { const $container = document.createElement('div'); $container.className = 'top-rated-movie'; $container.innerHTML = ` -
${data.vote_average.toFixed(1)}
+
`; $($container, '.rate').prepend(Star()); $($container, '.title').textContent = data.title; + $($container, 'button').addEventListener('click', () => { + onDetail(data.id); + }); + return $container; }; diff --git a/src/components/header/TopRateHeader.ts b/src/components/header/TopRateHeader.ts deleted file mode 100644 index b0d074910..000000000 --- a/src/components/header/TopRateHeader.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { getOriginalImageUrl } from '../../api/renderImage.ts'; -import { MovieData } from '../../api/types.ts'; -import { $ } from '../../utils/dom.ts'; -import { Logo } from './Logo.ts'; -import { Overlay } from './Overlay.ts'; -import { SearchForm } from './SearchForm.ts'; -import { TopRate } from './TopRate.ts'; - -export default class TopRateHeader { - #$element: HTMLElement; - - constructor(onSubmit: (query: string) => void) { - this.#$element = document.createElement('header'); - this.#$element.innerHTML = ` -
-
-
- `; - - const $justLayout = document.createElement('div'); - $(this.#$element, '.top-rated-container').append(Logo(), SearchForm(onSubmit), $justLayout); - $(this.#$element, '.background-container').prepend(Overlay()); - } - - get $element() { - return this.#$element; - } - - render(data: MovieData) { - $(this.#$element, '.background-container').style.backgroundImage = - `url(${getOriginalImageUrl(data.backdrop_path)})`; - $(this.#$element, '.top-rated-container').append(TopRate(data)); - } -} diff --git a/src/components/main/Error.ts b/src/components/main/Error.ts deleted file mode 100644 index 74cbacd34..000000000 --- a/src/components/main/Error.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const Error = (message: string = '에러가 났습니다') => { - const $div = document.createElement('div'); - $div.className = 'nothing'; - $div.innerHTML = ` - nothing -

${message}

- `; - - return $div; -}; diff --git a/src/components/main/Main.ts b/src/components/main/Main.ts index a8eb26e56..e6aa55fc6 100644 --- a/src/components/main/Main.ts +++ b/src/components/main/Main.ts @@ -1,7 +1,6 @@ import { MovieData } from '../../api/types.ts'; import { $ } from '../../utils/dom.ts'; -import { Error } from './Error.ts'; -import { MoreButton } from './MoreButton.ts'; +import { ErrorComponent } from '../common/ErrorComponent.ts'; import { MovieItem } from './MovieItem.ts'; import { MovieItemSkeleton } from './MovieItemSkeleton.ts'; import { NothingResult } from './NothingResult.ts'; @@ -9,10 +8,9 @@ import { NothingResult } from './NothingResult.ts'; export default class Main { #$element: HTMLElement; #$list: HTMLElement; - #$skeletons: HTMLElement[] = []; - #$moreButton: HTMLElement | null; + #$skeletons: HTMLElement[] | undefined; - constructor(title: string) { + constructor(title: string, onDetail: (movie_id: number) => void) { this.#$element = document.createElement('div'); this.#$element.className = 'container'; this.#$element.innerHTML = ` @@ -24,7 +22,13 @@ export default class Main { `; this.#$list = $(this.#$element, '.thumbnail-list'); - this.#$moreButton = null; + + const $ul = $(this.#$element, 'ul'); + $ul.addEventListener('click', (e) => { + const $li = (e.target as Element).closest('li'); + const id = $li?.dataset.id; + onDetail(Number(id)); + }); } get $element() { @@ -36,32 +40,23 @@ export default class Main { const $fragment = new DocumentFragment(); movies.forEach((movie) => $fragment.append(MovieItem(movie))); this.#$list.append($fragment); + return this.#$list.lastElementChild; } renderSkeletons(length: number = 20) { - this.#$skeletons = Array.from({ length }, () => MovieItemSkeleton()); - this.#$skeletons.forEach(($skeleton) => this.#$list.append($skeleton)); + const $newSkeletons = Array.from({ length }, () => MovieItemSkeleton()); + this.#$skeletons = $newSkeletons; + $newSkeletons.forEach(($skeleton) => this.#$list.append($skeleton)); } removeSkeletons() { - this.#$skeletons.forEach(($skeleton) => $skeleton.remove()); - this.#$skeletons = []; - } - - renderMoreButton(onClick: () => void) { - this.#$moreButton = MoreButton(onClick); - $(this.#$element, 'section').append(this.#$moreButton); - } - - removeMoreButton() { - this.#$moreButton?.remove(); - this.#$moreButton = null; + this.#$skeletons?.forEach(($skeleton) => $skeleton.remove()); } - renderError(messsage: string) { + handleError(error: Error) { const $element = $(this.#$element, 'section'); $element.innerHTML = ''; - $element.append(Error(messsage)); + $element.append(ErrorComponent(error)); } renderNothing() { diff --git a/src/components/main/MoreButton.ts b/src/components/main/MoreButton.ts index e9bb27cc2..66fb9fd73 100644 --- a/src/components/main/MoreButton.ts +++ b/src/components/main/MoreButton.ts @@ -1,8 +1,8 @@ -export const MoreButton = (onClick: () => void): HTMLElement => { - const $button = document.createElement('button'); - $button.className = 'more-button'; - $button.textContent = '더 보기'; - $button.addEventListener('click', onClick); +// export const MoreButton = (onClick: () => void): HTMLElement => { +// const $button = document.createElement('button'); +// $button.className = 'more-button'; +// $button.textContent = '더 보기'; +// $button.addEventListener('click', onClick); - return $button; -}; +// return $button; +// }; diff --git a/src/components/main/MovieItem.ts b/src/components/main/MovieItem.ts index ae5170118..ca7031bdc 100644 --- a/src/components/main/MovieItem.ts +++ b/src/components/main/MovieItem.ts @@ -3,10 +3,13 @@ import { $ } from '../../utils/dom.ts'; import { Star } from '../common/Star.ts'; import { MovieData } from '../../api/types.ts'; -export const MovieItem = (data: MovieData) => { - const { title, poster_path, vote_average } = data; +type MovieItemProps = Pick; + +export const MovieItem = (data: MovieItemProps) => { + const { id, title, poster_path, vote_average } = data; const $li = document.createElement('li'); + $li.dataset.id = String(id); $li.innerHTML = `
diff --git a/src/components/modal/Modal.ts b/src/components/modal/Modal.ts new file mode 100644 index 000000000..69e6c6ac3 --- /dev/null +++ b/src/components/modal/Modal.ts @@ -0,0 +1,111 @@ +import { getOriginalImageUrl } from '../../api/renderImage'; +import { MovieDetail } from '../../api/types'; +import { MovieStore } from '../../storage/types'; +import { $ } from '../../utils/dom'; +import { Star } from '../common/Star'; +import SubmitRate from './SubmitRate'; + +export default class Modal { + #movieStore: MovieStore; + + #$modal: HTMLElement; + #$modalImg: HTMLImageElement; + #$body: HTMLElement; + + constructor(movieRepo: MovieStore, $body: HTMLElement) { + this.#movieStore = movieRepo; + this.#$body = $body; + this.#$modal = document.createElement('div'); + + this.#$modal.id = 'modalBackground'; + this.#$modal.className = 'modal-background'; + this.#$modal.innerHTML = /*html */ ` + + `; + + this.#$modalImg = $(this.#$modal, '.modal-image img'); + + $(this.#$modal, 'button').addEventListener('click', () => this.close()); + window.addEventListener('keydown', (e) => { + if ((e as KeyboardEvent).key === 'Escape') { + this.close(); + } + }); + } + + get $element() { + return this.#$modal; + } + + #update(movie: MovieDetail) { + const { title, release_date, overview, poster_path, genres, vote_average } = movie; + + this.#$modalImg.src = getOriginalImageUrl(poster_path); + + $(this.#$modal, 'h2').textContent = title; + + const overViewString = overview ? overview : '줄거리 데이터가 없습니다'; + $(this.#$modal, '.detail').textContent = overViewString; + + const releaseYear = new Date(release_date).getFullYear(); + const category = genres.map((g) => g.name).join(' '); + $(this.#$modal, '.category').textContent = `${releaseYear} · ${category}`; + + const $rateContainer = $(this.#$modal, '.rate'); + $rateContainer.innerHTML = ''; + + const $starIcon = Star(true); + const $score = document.createElement('span'); + $score.textContent = `평균 ${Number(vote_average).toFixed(1)}`; + + $rateContainer.append($starIcon, $score); + } + + async open(movie: MovieDetail) { + this.#$body.className = 'modal-open'; + this.#$modal.classList.add('active'); + this.#update(movie); + + const { id } = movie; + const movieRate = Number(await this.#movieStore.get(id)) || 0; + + const $submitRate = new SubmitRate(movieRate, async (rate) => { + await this.#movieStore.save(id, rate); + }).$element; + + const $container = $(this.#$modal, '.modal-submit-star'); + const $oldCon = $container.querySelector('.submit-rate-container'); + if ($oldCon) $oldCon.remove(); + $container.append($submitRate); + } + + renderSkeleton() { + this.#$modalImg.src = './images/empty.png'; + } + + close() { + this.#$body.classList.remove('modal-open'); + this.#$modal.classList.remove('active'); + } +} diff --git a/src/components/modal/SubmitRate.ts b/src/components/modal/SubmitRate.ts new file mode 100644 index 000000000..9392e745a --- /dev/null +++ b/src/components/modal/SubmitRate.ts @@ -0,0 +1,54 @@ +import { Star } from '../common/Star.ts'; +const POINTS = ['최악이예요', '별로예요', '보통이에요', '재미있어요', '명작이에요'] as const; + +export default class SubmitRate { + #$submiteRateContainer: HTMLElement; + #$starContanier: HTMLElement; + #$rateText: HTMLElement; + + #onSubmitRate: (rate: number) => void; + + constructor(rate: number, onSubmitRate: (rate: number) => void) { + this.#onSubmitRate = onSubmitRate; + + this.#$submiteRateContainer = document.createElement('div'); + this.#$submiteRateContainer.className = 'submit-rate-container'; + + this.#$starContanier = document.createElement('div'); + this.#$starContanier.className = 'star-container'; + + const $rateTextContainer = document.createElement('div'); + this.#$rateText = document.createElement('p'); + $rateTextContainer.append(this.#$rateText); + + this.#$submiteRateContainer.append(this.#$starContanier); + this.#$submiteRateContainer.append($rateTextContainer); + + this.#renderStar(rate); + } + + get $element() { + return this.#$submiteRateContainer; + } + + #renderStar = (rate: number = 0) => { + this.#$starContanier.innerHTML = ''; + const rateText = rate !== 0 ? `${POINTS[rate / 2 - 1]} ${rate}/10` : '별점을 입력해주세요'; + this.#$rateText.textContent = rateText; + + POINTS.forEach((point, index) => { + const score = (index + 1) * 2; + const $button = document.createElement('button'); + $button.className = 'submit-star-button'; + + $button.append(Star(score <= rate)); + $button.addEventListener('click', () => { + this.#$rateText.textContent = point; + this.#onSubmitRate(score); + this.#renderStar(score); + }); + + this.#$starContanier.append($button); + }); + }; +} diff --git a/src/main.ts b/src/main.ts index db14aabaa..f1634df11 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,38 +1,15 @@ -import HomePage from './pages/HomePage.ts'; -import SearchPage from './pages/SearchPage.ts'; -import { ROUTE_CHANGE_EVENT } from './utils/event.ts'; +import { router } from './router.ts'; +import LocalStorage from './storage/LocalStorage.ts'; -const routes = [ - { path: '/', view: HomePage }, - { path: '/search', view: SearchPage }, -]; +window.addEventListener('load', () => { + const $body = document.querySelector('body'); + if (!$body) return; -const router = () => { - const $app = document.querySelector('#app'); - if (!$app) return; + const movieDB = new LocalStorage(); - $app.innerHTML = ''; + $body.append(); - const fullHash = location.hash.replace('#', '') || '/'; - const [path, queryString] = fullHash.split('?'); - const match = routes.find((route) => route.path === path); + window.addEventListener('hashchange', () => router(movieDB)); - const View = match ? match.view : HomePage; - const page = new View(); - $app.replaceChildren(page.$element); -}; - -const navigateTo = (url: string) => { - location.hash = url; -}; - -window.addEventListener(ROUTE_CHANGE_EVENT, (e: Event) => { - const customEvent = e as CustomEvent<{ url: string }>; - const { url } = customEvent.detail; - navigateTo(url); -}); - -addEventListener('load', () => { - window.addEventListener('hashchange', router); - router(); + router(movieDB); }); diff --git a/src/pages/HomePage.ts b/src/pages/HomePage.ts deleted file mode 100644 index d284857a0..000000000 --- a/src/pages/HomePage.ts +++ /dev/null @@ -1,85 +0,0 @@ -import Header from '../components/header/TopRateHeader.ts'; -import Main from '../components/main/Main.ts'; -import Footer from '../components/footer/Footer.ts'; - -import { fetchPopularMovies } from '../api/fetchApi.ts'; -import { ResponseMovie } from '../api/types.ts'; -import TMDBError from '../api/TMDBError.ts'; -import { dispatchRouteChange } from '../utils/event.ts'; - -export default class HomePage { - #$fragment: DocumentFragment; - #page: number = 1; - #header: Header; - #main: Main; - #footer: Footer; - - constructor() { - this.#$fragment = document.createDocumentFragment(); - - this.#header = new Header(this.#onSubmit); - this.#main = new Main('지금 인기있는 영화'); - this.#footer = new Footer(); - - this.#$fragment.append(this.#header.$element, this.#main.$element, this.#footer.$element); - - this.#initialFetch(); - } - - get $element() { - return this.#$fragment; - } - - async #initialFetch() { - try { - const response = await this.#appendMovies(); - this.#header.render(response.results[0]); - } catch (error) { - this.#handleError(error); - } - } - - async #loadMore() { - this.#main.removeMoreButton(); - this.#page += 1; - await this.#appendMovies(); - } - - async #appendMovies(): Promise { - this.#main.renderSkeletons(); - try { - const response = await fetchPopularMovies(this.#page); - this.#main.renderMovies(response.results); - - if (this.#page < response.total_pages) { - this.#main.renderMoreButton(() => this.#loadMore()); - } - return response; - } catch (error) { - this.#handleError(error); - throw error; - } finally { - this.#main.removeSkeletons(); - } - } - - #handleError(error: unknown) { - if (error instanceof TMDBError) { - this.#main.renderError(`TMDB 에러: ${error.message}`); - return; - } - - if (error instanceof Error) { - this.#main.renderError(`시스템 에러: ${error.message}`); - return; - } - - this.#main.renderError('알 수 없는 에러가 발생했습니다.'); - } - - #onSubmit = (query: string): void => { - if (query.trim()) { - dispatchRouteChange(`/search?query=${encodeURIComponent(query)}`); - } - }; -} diff --git a/src/pages/MoviePage.ts b/src/pages/MoviePage.ts new file mode 100644 index 000000000..59951e118 --- /dev/null +++ b/src/pages/MoviePage.ts @@ -0,0 +1,127 @@ +import Main from '../components/main/Main.ts'; +import Footer from '../components/footer/Footer.ts'; +import Modal from '../components/modal/Modal.ts'; + +import { MovieDetail, ResponseMovie } from '../api/types.ts'; +import LocalStorage from '../storage/LocalStorage.ts'; +import Header from '../components/header/Header.ts'; +import { MovieStore } from '../storage/types.ts'; + +type PageOption = { + type: 'home' | 'search'; + fetchMovie: (page: number) => Promise; + fetchDetail: (movie_id: number) => Promise; + onSubmit: (query: string) => void; + query?: string; + movieDB: MovieStore; +}; + +export default class MoviePage { + #$div: HTMLElement; + #$header: Header; + #$main: Main; + #$modal: Modal; + + #totalPage: number; + #page: number; + #option: PageOption; + + #observer: IntersectionObserver; + + constructor(option: PageOption) { + this.#page = 1; + this.#totalPage = 1; + this.#option = option; + + this.#$div = document.createElement('div'); + this.#$header = new Header(option.onSubmit.bind(this), this.#onDetail.bind(this)); + const title = option.type === 'home' ? '지금 인기있는 영화' : `"${option.query}" 검색 결과`; + this.#$main = new Main(title, this.#onDetail.bind(this)); + this.#$modal = new Modal(option.movieDB, this.#$div); + + const footer = new Footer(); + this.#$div.append(this.#$header.$element, this.#$main.$element, footer.$element, this.#$modal.$element); + + this.#observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + this.#loadMore(); + } + }, + { threshold: 0.1 }, + ); + this.#initialFetch(); + } + + get $element() { + return this.#$div; + } + + async #fetchMovie(): Promise { + if (this.#page > this.#totalPage) return; + const response = await this.#option.fetchMovie(this.#page); + if (response.results.length === 0) { + this.#$main.renderNothing(); + return; + } + this.#page += 1; + this.#totalPage = response.total_pages; + return response; + } + + async #initialFetch(): Promise { + try { + this.#$main.renderSkeletons(); + const response = await this.#fetchMovie(); + if (!response) return; + + if (this.#option.type === 'home') this.#$header.showBanner(response.results[0]); + + const lastElement = this.#appendMovies(response); + if (lastElement) this.#observer.observe(lastElement); + } catch (error) { + this.#handleError(error); + } finally { + this.#$main.removeSkeletons(); + } + } + + async #loadMore(): Promise { + this.#observer.disconnect(); + try { + this.#$main.renderSkeletons(); + const response = await this.#fetchMovie(); + if (!response) return; + + const lastElement = this.#appendMovies(response); + if (lastElement) this.#observer.observe(lastElement); + } catch (error) { + this.#handleError(error); + } finally { + this.#$main.removeSkeletons(); + } + } + + #appendMovies(response: ResponseMovie): Element | null { + return this.#$main.renderMovies(response.results); + } + + #handleError(error: unknown) { + if (error instanceof Error) { + console.error(error); + this.#$main.handleError(error); + } + } + + async #onDetail(movie_id: number) { + try { + this.#$modal.renderSkeleton(); + const movie = await this.#option.fetchDetail(movie_id); + this.#$modal.open(movie); + } catch (error) { + console.error(error); + alert('모달 에러입니다.'); + this.#handleError(error as Error); + } + } +} diff --git a/src/pages/SearchPage.ts b/src/pages/SearchPage.ts deleted file mode 100644 index 6ee40f319..000000000 --- a/src/pages/SearchPage.ts +++ /dev/null @@ -1,96 +0,0 @@ -import Header from '../components/header/SearchHeader.ts'; -import Main from '../components/main/Main.ts'; -import Footer from '../components/footer/Footer.ts'; - -import { fetchSearchMovies } from '../api/fetchApi.ts'; -import { ResponseMovie } from '../api/types.ts'; -import TMDBError from '../api/TMDBError.ts'; -import { dispatchRouteChange } from '../utils/event.ts'; - -export default class SearchPage { - #$fragment: DocumentFragment; - #page: number = 1; - #main: Main; - - constructor() { - this.#$fragment = document.createDocumentFragment(); - const query = this.#getQuery(); - const header = new Header(this.#onSubmit); - this.#main = new Main(`"${query}" 검색 결과`); - const footer = new Footer(); - - this.#$fragment.append(header.$element, this.#main.$element, footer.$element); - - this.#initialFetch(); - } - - get $element() { - return this.#$fragment; - } - - #getQuery(): string { - const [, queryString = ''] = window.location.hash.split('?'); - const urlParams = new URLSearchParams(queryString); - return urlParams.get('query') ?? ''; - } - - async #initialFetch() { - try { - await this.#appendMovies(); - } catch (error) { - console.error('Search fetch failed:', error); - } - } - - async #loadMore() { - this.#main.removeMoreButton(); - this.#page += 1; - await this.#appendMovies(); - } - - async #appendMovies(): Promise { - this.#main.renderSkeletons(); - - try { - const response = await fetchSearchMovies(this.#getQuery(), this.#page); - - if (response.results.length === 0) { - this.#main.renderNothing(); - return response; - } - - this.#main.renderMovies(response.results); - - if (this.#page < response.total_pages) { - this.#main.renderMoreButton(() => this.#loadMore()); - } - - return response; - } catch (error) { - this.#handleError(error); - throw error; - } finally { - this.#main.removeSkeletons(); - } - } - - #handleError(error: unknown) { - if (error instanceof TMDBError) { - this.#main.renderError(`TMDB 에러: ${error.message}`); - return; - } - - if (error instanceof Error) { - this.#main.renderError(`시스템 에러: ${error.message}`); - return; - } - - this.#main.renderError('알 수 없는 에러가 발생했습니다.'); - } - - #onSubmit = (query: string): void => { - if (query.trim()) { - dispatchRouteChange(`/search?query=${encodeURIComponent(query)}`); - } - }; -} diff --git a/src/pages/createPage.ts b/src/pages/createPage.ts new file mode 100644 index 000000000..2b1f0aa7e --- /dev/null +++ b/src/pages/createPage.ts @@ -0,0 +1,30 @@ +import MoviePage from './MoviePage.ts'; +import { fetchMovieDetails, fetchPopularMovies, fetchSearchMovies } from '../api/fetchApi.ts'; +import { MovieStore } from '../storage/types.ts'; + +const onSubmit = (query: string): void => { + if (query.trim()) { + location.hash = `/search?query=${encodeURIComponent(query)}`; + } +}; + +export const createHomePage = (movieDB: MovieStore): MoviePage => { + return new MoviePage({ + type: 'home', + fetchMovie: (page: number) => fetchPopularMovies(page), + fetchDetail: (movie_id: number) => fetchMovieDetails(movie_id), + onSubmit, + movieDB, + }); +}; + +export const createSearchPage = (query: string, movieDB: MovieStore): MoviePage => { + return new MoviePage({ + type: 'search', + fetchMovie: (page: number) => fetchSearchMovies(query, page), + fetchDetail: (movie_id: number) => fetchMovieDetails(movie_id), + onSubmit, + query, + movieDB, + }); +}; diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 000000000..87023def0 --- /dev/null +++ b/src/router.ts @@ -0,0 +1,16 @@ +import { createHomePage, createSearchPage } from './pages/createPage.ts'; +import { MovieStore } from './storage/types.ts'; + +export const router = (movieDB: MovieStore) => { + const $app = document.querySelector('#app'); + if (!$app) return; + + const fullHash = location.hash.replace('#', '') || '/'; + const [path, queryString] = fullHash.split('?'); + const query = new URLSearchParams(queryString).get('query') ?? ''; + + $app.innerHTML = ''; + + const newPage = path === '/search' ? createSearchPage(query, movieDB) : createHomePage(movieDB); + $app.append(newPage.$element); +}; diff --git a/src/storage/LocalStorage.ts b/src/storage/LocalStorage.ts new file mode 100644 index 000000000..aa0b575cf --- /dev/null +++ b/src/storage/LocalStorage.ts @@ -0,0 +1,16 @@ +import { MovieStore } from './types'; +export default class LocalStorage implements MovieStore { + #myStorage; + + constructor() { + this.#myStorage = window.localStorage; + } + + async save(key: number, value: number): Promise { + this.#myStorage.setItem(String(key), String(value)); + } + + async get(key: number): Promise { + return this.#myStorage.getItem(String(key)); + } +} diff --git a/src/storage/types.ts b/src/storage/types.ts new file mode 100644 index 000000000..02ace1615 --- /dev/null +++ b/src/storage/types.ts @@ -0,0 +1,4 @@ +export interface MovieStore { + get(key: number): Promise; + save(key: number, value: number): Promise; +} diff --git a/src/utils/event.ts b/src/utils/event.ts deleted file mode 100644 index 1ff3c1aeb..000000000 --- a/src/utils/event.ts +++ /dev/null @@ -1,6 +0,0 @@ -// 커스텀이벤터패턴 나중에공부 -export const ROUTE_CHANGE_EVENT = 'ROUTE_CHANGE'; - -export const dispatchRouteChange = (url: string) => { - window.dispatchEvent(new CustomEvent(ROUTE_CHANGE_EVENT, { detail: { url } })); -}; diff --git a/styles/main.css b/styles/main.css index 6486eaa83..430b3c3c8 100644 --- a/styles/main.css +++ b/styles/main.css @@ -75,8 +75,10 @@ button.primary { } .background-container { + width: 100%; + height: 100%; position: relative; - background-position: center center; + background-position: center; background-size: cover; padding: 48px; } @@ -92,23 +94,40 @@ button.primary { z-index: 1; } -.top-rated-container { +.banner-container { display: grid; + user-select: none; + position: relative; + z-index: 2; + max-width: 1280px; + margin: 0 auto; grid-template-columns: 1fr 1fr 1fr; +} +.banner-container { + display: grid; user-select: none; position: relative; z-index: 2; max-width: 1280px; margin: 0 auto; + grid-template-columns: 1fr 1fr 1fr; +} + +.top-header-container { + min-height: 500px; } -.top-rated-container .search-form { +.banner-container .search-form, +.top-rated-container .search-form, +.search-container .search-form { justify-self: center; } .top-rated-movie { + position: relative; margin-top: 64px; + z-index: 2; } .top-rated-movie > *:not(:last-child) { @@ -117,6 +136,7 @@ button.primary { h1.logo { font-size: 2rem; + width: 175px; } .rate { @@ -166,10 +186,8 @@ span.rate-value { display: flex; justify-content: space-between; align-items: center; - width: 525px; height: 36px; - border: 2px solid var(--color-ffffff); border-radius: 32px; outline: none; @@ -280,6 +298,36 @@ form { margin: 20px; } -.top-header-container { - min-height: 500px; +@media (max-width: 800px) { + .search-wrap { + width: 325px; + height: 36px; + } + + h1.logo { + width: 100%; + text-align: center; + } + + section h2 { + text-align: center; + } + + .top-header-container { + min-height: 300px; + width: 100%; + } + .top-rated-container, + .search-container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } +} + +@media (max-width: 390px) { + .search-wrap { + width: 200px; + } } diff --git a/styles/modal.css b/styles/modal.css index 0cf24b4dd..e19805d54 100644 --- a/styles/modal.css +++ b/styles/modal.css @@ -58,6 +58,7 @@ body.modal-open { .modal-image img { width: 380px; + height: 570px; border-radius: 16px; } @@ -86,3 +87,85 @@ body.modal-open { max-height: 430px; overflow-y: auto; } + +.average-info { + color: var(--color-white); + margin-right: 4px; +} + +.submit-rate-container { + display: grid; + grid-template-columns: 1fr 1fr; +} +.submit-star-button { + border: 0; + background-color: transparent; + margin: 0; + padding: 0; +} + +@media (max-width: 800px) { + .modal-background { + align-items: flex-end; + } + + .modal { + border-radius: 0; + bottom: 0; + width: 800px; + height: 70%; + overflow: scroll; + } + + .modal-image img { + width: 200px; + height: 295px; + border-radius: 16px; + } + .modal-container { + padding: 40px 24px; + gap: 10px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } + + .modal-description h2 { + font-size: 32px; + } +} + +@media (max-width: 390px) { + .modal { + top: 20px; + bottom: 0; + gap: 24px; + opacity: 1; + padding: 40px 24px; + width: 390px; + border-radius: 16px; + } + + .modal-image img { + display: none; + } + .rate { + text-align: center; + } + .modal-description > div > h3 { + text-align: center; + } + .modal-submit-star, + .submit-rate-container, + .modal-description { + display: flex; + justify-items: center; + align-items: center; + flex-direction: column; + } + + hr { + width: 100%; + } +} diff --git a/styles/thumbnail.css b/styles/thumbnail.css index 3f78cbef7..c4a91ee9f 100644 --- a/styles/thumbnail.css +++ b/styles/thumbnail.css @@ -1,14 +1,16 @@ @import './colors.css'; .thumbnail-list { - margin: 0 auto 56px; display: grid; - grid-template-columns: repeat(5, 200px); - gap: 70px; + justify-content: center; + width: 100%; + margin: 0 auto 56px; + grid-template-columns: repeat(auto-fit, 200px); + gap: 40px; } .thumbnail { - width: 200px; + width: 100%; height: 300px; border-radius: 8px; }