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 = `
+
+ ${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 = `
-
- ${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;
}