Skip to content

Commit

Permalink
[2단계 - 상세 정보 & UI/UX 개선하기] 초코(강다빈) 미션 제출합니다. (#171)
Browse files Browse the repository at this point in the history
* feat: eslint와 prettier 기본 세팅

* feat: eslint, prettier 패키지 설치

* feat: eslint error를 warn으로 설정 변경

Co-Authored-By: MYONG JAEWI <[email protected]>

* chore: ts-eslint parse 설정

Co-Authored-By: MYONG JAEWI <[email protected]>

* feat: image 소스 파일 추가

Co-Authored-By: MYONG JAEWI <[email protected]>

* feat : css - common.css, reset.css 추가

Co-Authored-By: MYONG JAEWI <[email protected]>

* feat: index.html 템플릿 설정

Co-Authored-By: MYONG JAEWI <[email protected]>

* docs: 기능 요구 사항 정리

Co-Authored-By: MYONG JAEWI <[email protected]>

* feat: mockingData 설정

Co-Authored-By: MYONG JAEWI <[email protected]>

* feat: MovieCard 컴포넌트 구현

- mockingData 사용
- template을 컴포넌트화

Co-Authored-By: MYONG JAEWI <[email protected]>

* chore: eslint - browser env : true로 변경

Co-Authored-By: 00kang <[email protected]>

* feat: mockingData 20개로 업데이트

Co-Authored-By: 00kang <[email protected]>

* chore: webpack-dev-server - 설정 및 이미지 파일 경로

Co-Authored-By: 00kang <[email protected]>

* refactor: MovieCard 메서드 분리

Co-Authored-By: 00kang <[email protected]>

* feat: index.js에 css import

Co-Authored-By: 00kang <[email protected]>

* feat: MoreButton 컴포넌트

Co-Authored-By: 00kang <[email protected]>

* feat: API에서 받아온 데이터 렌더링

mocking 데이터 제거

Co-Authored-By: 00kang <[email protected]>

* feat: MovieCard 영화 데이터 주입

Co-Authored-By: 00kang <[email protected]>

* feat: 더보기 버튼 클릭 시, 다음 페이지 영화 렌더링

Co-Authored-By: 00kang <[email protected]>

* feat: 영화 검색 기능

Co-Authored-By: 00kang <[email protected]>

* feat: skeleton UI 적용

Co-Authored-By: 00kang <[email protected]>

* feat: 영화 검색 기능 추가, page가 더 있을 때에만 더보기 버튼 렌더링

* feat: 검색 결과가 없을때, UI 표시

* feat: 현재 영화 리스트에 따른 리스트 Title 변경

* feat: 응답에 따른 에러 처리

* fix: 검색어 입력 후 Enter 누를 시, 두번 event가 발생하는 버그 수정

* fix: 검색어 입력마다 presentPage count 리셋

검색 -> 더보기 버튼 클릭 -> 다시 검색 시, presentPage count가 유지되는 버그 수정

* fix: skeleton li element가 완전히 제거되지 않는 버그 수정

* feat: 검색 후 home으로 돌아올 시, 기존 가져온 인기 영화 render

* style: 영화 평점 css

* chore: cypress 설정

* chore: gitignore - dist,, env 추가

* chore: tsconfig 옵션 추가

* test: 인기순 영화 페이지 E2E 테스트

* chore: gitignore에 cypress.env.json 추가

Co-Authored-By: 00kang <[email protected]>

* test: TMDB에서 인기순 영화 GET요청 API 테스트

Co-Authored-By: 00kang <[email protected]>

* test: TMDB에서 영화 검색 GET요청 API 테스트

Co-Authored-By: 00kang <[email protected]>

* test: Fixture를 이용한 인기순 영화 목록 테스트

Co-Authored-By: 00kang <[email protected]>

* test: Fixture를 이용한 영화 검색 결과 렌더링 테스트

Co-Authored-By: 00kang <[email protected]>

* test: fixture - search 데이터 업데이트

Co-Authored-By: 00kang <[email protected]>

* chore: output Path 수정

* refactor: image 디렉토리 위치 변

* refactor: css 디렉토리 위칭 이동 및 css variables 생성

* refactor: 매직넘버 상수화

* refactor: ErrorRender로 클래스명 수정

* refactor: SearchBox 컴포넌트 내 함수 이름 변경 및 메서드 분리

- onClick- > searchBoxElement
- #addFormEvent  -> #addFormEvent  + #handleSubmit

* refactor: MovieStore와 SearchMovieStore에서 API 호출 로직 분리

* refactor: DocumentFragement 사용으로 DOM 접근 줄이기

* refactor: early return으로 depth 줄이기

* refactor: #genereateMovieList와 #generateSearchMovieList의 중복 코드 메서드 분리

* refctor: 헤더 고정, 포스터 이미지 없는 데이터 이미지 삽입

* refactor: 검색결과에 검색어(해리)가 포함되어 있는지 test 코드 추가

* feat: deploy 디렉토리에 css와 png 추가

* chore: eslint, package, tsconfig 설정

* docs: 구현 기능 목록 작성

* feat: 스켈레톤의 <a> 태그 제거

* feat: 임의 데이터를 활용한 모달창 오픈

* feat: 모달 클로즈 기능 구현

- 무비카드 클릭 이벤트 메서드 분리
- 모달 인스턴스 공유 메서드 생성

* feat: movieDetail API로부터 데이터 받아와서 모달에 보여주기

* fix: 이미지 영역 고정

- 이미지가 다 로드되기 전에는 컨텐츠 영역이 이미지 영역을 침범하는 문제 해결

* fix: 모달 내 텍스트 영역 조정

- overview 데이터가 길 경우를 대비해 크기 지정 및 스크롤 기능 추가

* fix: 모달 클로즈 오류 비동기 키워드 사용으로 해결

- 비동기 작업이 완료되기 전에 그 결과를 사용하려고 시도하는 경우

* feat: 클릭한 무비카드에 맞는 데이터 모달에 띄우기

* feat: API 요청 메서드 분리

* feat: 더보기 버튼 대신 무한 스크롤 기능 구현

- 그런데 안됨!!

* refactor: 데이터 로드시 스켈레톤 ui 확인을 위한 딜레이 메서드 삭제

* feat: 모달 내 별점 0으로 세팅

* feat: 모달 내 별점 클릭 이벤트 구현

- 클릭한 지점까지 'star_filled.png'로 채우기

* feat: 모달 내 평점 소수점 2자리까지 표현

* feat: tablet size 반응형 디자인 적용

* feat: tablet size 일 때 스켈레톤 ui 6개로 변경

* feat: mobile size일 때 그리드 조절

* feat: mobile size일 때 스켈레톤 4개로 조정

* feat: mobile size일 때 모달 사이즈 수정

* feat: npm run build

* feat: 스크립트 추가 후 npm run build

* feat: npm run build

* feat: npm run build

* feat: import문 삽입

* refactor: mobile, tablet 사이즈 기준 상수화

* refactor: let 연산자 삭제

* refactor: moviesData의 타입 명확히 Movie[]로 지정

* feat: 무한 스크롤 구현!

* refactor: 반응형 디자인 수정

- 레이아웃 기준 재설정
- 헤더 넘침으로 인한 x축 스크롤 이벤트 해결

* feat: 모달 내 별점 localstorage에 반영 및 localstorage에 저장된 결과 보여주기

* feat: ESC key 로 모달 클로즈

* docs: 업데이트

* feat: 영화 포스터와 검색창 hover시 border 디자인

* feat: 최상단으로 이동하는 버튼

* refactor: 모달 디테일 수정

- overview가 없을 시 문구 삽입
- 모달이 오픈되었을 때 백그라운드 스크롤 금지
- 평점의 별 이미지와 텍스트 정렬
- 모달 내 별점 컨테이너의 요소 위치 고정

* fix: 모달 오픈시 무비리스트의 스크롤이 최상단으로 가는 문제 해결

* refactor: store 코드 메서드 분리

* fix: 없는 데이터에 forEach() 를 사용하는 문제

* refactor: response 반환

* refactor: 메서드 분리

* refactor: return new Error

* refactor: 스크립트 파일 제거

* fix: 초기 세팅 시 별 이미지 안 뜨는 문제

* refactor: movieCard 클래스 메서드 분리

* refactor: Modal 클래스 메서드 분리

* refactor: Modal 클래스 분리 : Modal, MovieInfo, VoteHandler

* feat: 모달에 skeleton UI 추가

* refactor: 반응형 디자인 수정 및 모달 스켈레톤 UI 적용

---------

Co-authored-by: jayming66 <[email protected]>
Co-authored-by: MYONG JAEWI <[email protected]>
  • Loading branch information
3 people authored Apr 4, 2024
1 parent 9b3ac21 commit 22d0448
Show file tree
Hide file tree
Showing 30 changed files with 1,682 additions and 135 deletions.
3 changes: 3 additions & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
"no-promise-executor-return": "off",
"consistent-return": "off",
"no-return-await": "off",
"no-plusplus": "off",
"camelcase": "off",
"object-curly-newline": "off",
},
"env": {
"es6": true,
Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
node_modules
dist
.env
cypress.env.json
Binary file added deploy/images/modal_close_button.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added dist/06f0f15cfcb8d681b62c.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added dist/2e162b4fefb34cd7ed8d.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added dist/6328741810b732410eec.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added dist/6c9611deedf4b85849c9.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
484 changes: 484 additions & 0 deletions dist/bundle.js

Large diffs are not rendered by default.

Binary file added dist/e9a379619c759f886f7c.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added dist/f1bd4269f4446ceae306.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 26 additions & 0 deletions dist/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

<title>영화관</title>
<script defer type="module" src="./dist/bundle.js"></script>
<script defer src="bundle.js"></script></head>
<body>
<div id="app">
<header>
<h1>
<button id="home-button"></button>
</h1>
</header>
<main>
<section class="item-view">
<h2></h2>
<ul class="item-list"></ul>
</section>
</main>
</div>
</body>
</html>
24 changes: 23 additions & 1 deletion docs/REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
- 실제 동작하는 API를 통한 비동기 통신
- UX 경험 개선을 위한 더 보기(페이징) 구현

## 구현할 기능 목록
## STEP1 구현할 기능 목록

### 1. 🎬 영화 목록 조회 (인기순)

Expand Down Expand Up @@ -32,3 +32,25 @@
- [x] 검색 결과가 없을 경우 (응답은 있으나)
- [x] HTTP 4xx 오류일 때 (클라이언트 오류)
- [x] HTTP 5xx 오류일 때 (서버 오류)

## STEP2 구현할 기능 목록

### 1. 📺 영화 상세정보 조회

- [x] 영화 포스터나 제목을 클릭하면 자세한 예고편이나 줄거리 등의 정보를 보여준다.
- [x] API에서 제공하는 항목을 활용하여 상세 정보를 보여주는 모달 창을 구현한다.
- [x] 키보드의 ESC 키를 누르면 모달 창을 닫을 수 있는 등 사용성을 고려한다.

### 2. ⭐️ 별점 매기기

- TMDB API 요청과는 관련 없습니다.
- [x] 사용자는 영화에 대해 별점을 줄 수 있다.
- [x] 새로고침하더라도 사용자가 남긴 별점은 유지되어야 한다.
- [x] 별점은 5개로 구성되어 있으며 한 개당 2점이며 1점 단위는 고려하지 않는다.
- 2점, 4점, 6점, 8점, 10점

### 3. 📐 UI⁄UX 개선하기

- [x] 영화 목록과 영화 상세 정보가 뜨는 모달창에 대한 반응형 레이아웃을 구성한다.
- [x] 영화 목록의 더보기 버튼을 무한 스크롤 방식으로 변경한다.
- 검색 결과 화면에서 사용자가 브라우저 화면의 끝에 도달하면 그 다음 20개의 목록을 서버에 요청하여 추가로 불러올 수 있다.
7 changes: 3 additions & 4 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,22 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

<link rel="stylesheet" href="./css/reset.css" type="text/css" />
<link rel="stylesheet" href="./css/common.css" type="text/css" />

<title>영화관</title>
<!-- <script defer type="module" src="./bundle.js"></script> -->
</head>
<body>
<div id="app">
<header>
<h1>
<button id="home-button"><img src="./images/logo.png" alt="MovieList 로고" /></button>
<button id="home-button"></button>
</h1>
</header>
<main>
<section class="item-view">
<h2></h2>
<ul class="item-list"></ul>
</section>
<button id="goToTop" class="text-detail-contents">TOP</button>
</main>
</div>
</body>
Expand Down
182 changes: 142 additions & 40 deletions src/App.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,57 @@
import { Movie } from './index.d';

import { SKELETON_UI_FIXED } from './constants';
import { SKELETON_UI_PC, SKELETON_UI_TABLET, SKELETON_UI_MOBILE, MOBILE_SIZE, TABLET_SIZE } from './constants';

import MoreButton from './components/MoreButton';
import MovieCard from './components/MovieCard';
import movieStore from './store/MovieStore';
import SearchBox from './components/SearchBox';
import searchMovieStore from './store/SearchMovieStore';

import SearchBox from './components/SearchBox';
import MovieCard from './components/MovieCard';
import Modal from './components/Modal';

import Logo from './images/logo.png';

type Tpage = 'popular' | 'search';

export default class App {
#pageType: Tpage = 'popular';

#observer: IntersectionObserver | null = null;

#isLoading: boolean = false;

#skeletonBySize: number = SKELETON_UI_PC;

async run() {
this.#insertLogo();
this.#generateMovieList();
this.#generateSearchBox();
this.#addHomeButtonEvent();
this.#initEventListeners();
this.#setupIntersectionObserver();
this.#goToTop();
}

#insertLogo() {
const homeButton = document.getElementById('home-button');
const imgElement = document.createElement('img');

imgElement.src = Logo;
imgElement.alt = 'MovieList 로고';

homeButton?.appendChild(imgElement);
}

#getSkeletonCount() {
const width = window.innerWidth;

if (width <= MOBILE_SIZE) {
return SKELETON_UI_MOBILE;
}
if (width <= TABLET_SIZE) {
return SKELETON_UI_TABLET;
}
return this.#skeletonBySize;
}

#generateMovieList() {
Expand All @@ -35,14 +70,22 @@ export default class App {
async #generateItemList(title: string, fetchData: () => Promise<Movie[]>, store: any) {
this.#changeTitle(title);
this.#removePreviousError();

const ulElement = document.querySelector('ul.item-list');
if (!ulElement) return;

if (ulElement) {
this.#generateSkeletonUI(ulElement as HTMLElement);
const newData = await fetchData();
this.#removeSkeletonUI();
this.#appendMovieCard(newData, ulElement as HTMLElement);
}
const skeletonCount = this.#getSkeletonCount();
this.#generateSkeletonUI(ulElement as HTMLElement, skeletonCount);

const newData = await fetchData();

this.#removeSkeletonUI();

if (!newData) return;

this.#appendMovieCard(newData, ulElement as HTMLElement);

this.#observeSentinel();
}

#changeTitle(title: string) {
Expand All @@ -62,20 +105,16 @@ export default class App {
ulElement?.appendChild(card.element);
});

this.#generateMoreButton();
this.#addSentinel();
}

// eslint-disable-next-line max-lines-per-function
#generateSkeletonUI(ulElement: HTMLElement) {
this.#removeMoreButton();

#generateSkeletonUI(ulElement: HTMLElement, skeletonCount: number) {
const fragment = new DocumentFragment();

for (let i = 0; i < SKELETON_UI_FIXED; i++) {
for (let i = 0; i < skeletonCount; i++) {
const card = new MovieCard({
classes: ['skeleton-container'],
});

fragment.appendChild(card.element);
}

Expand All @@ -92,36 +131,70 @@ export default class App {
}
}

/* eslint-disable max-lines-per-function */
#generateMoreButton() {
this.#removeMoreButton();
#setupIntersectionObserver() {
const options = {
root: null,
rootMargin: '0px',
threshold: 0.2,
};

if (searchMovieStore.presentPage === searchMovieStore.totalPages) return;
this.#observer = new IntersectionObserver(this.#handleIntersection, options);

const itemView = document.querySelector('section.item-view');

const moreBtn = new MoreButton({
onClick: () => {
if (this.#pageType === 'popular') {
movieStore.increasePageCount();
this.#generateMovieList();
} else {
searchMovieStore.increasePageCount();
this.#generateSearchMovieList();
}
},
});
this.#observeSentinel();
}

#addSentinel() {
const ulElement = document.querySelector('ul.item-list');

itemView?.appendChild(moreBtn.element);
if (ulElement) {
const sentinel = document.createElement('li');
sentinel.classList.add('sentinel');
ulElement.appendChild(sentinel);
this.#observeSentinel();
}
}

#removeMoreButton() {
const moreButton = document.getElementById('more-button');
if (moreButton) {
moreButton.parentNode?.removeChild(moreButton);
#removeSentinel() {
const sentinelElement = document.querySelector('li.sentinel');
if (sentinelElement) {
sentinelElement.remove();
}
}

#observeSentinel() {
const sentinel = document.querySelector('.sentinel');
if (sentinel && this.#observer) {
this.#observer.observe(sentinel);
}
}

#handleIntersection = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.isIntersecting && entry.intersectionRatio >= 0.2 && !this.#isLoading) {
this.#loadMoreMovies();
}
});
};

// eslint-disable-next-line max-lines-per-function
async #loadMoreMovies() {
if (searchMovieStore.presentPage === searchMovieStore.totalPages) return;

this.#removeSentinel();

this.#isLoading = true;

if (this.#pageType === 'popular') {
await movieStore.increasePageCount();
await this.#generateMovieList();
} else {
await searchMovieStore.increasePageCount();
await this.#generateSearchMovieList();
}

this.#isLoading = false;
}

#removePreviousError() {
const previousError = document.getElementById('error-page');

Expand All @@ -130,6 +203,7 @@ export default class App {
}
}

// eslint-disable-next-line max-lines-per-function
#generateSearchBox() {
const header = document.querySelector('header');
const ulElement = document.querySelector('ul.item-list');
Expand All @@ -154,7 +228,6 @@ export default class App {
this.#pageType = 'popular';
this.#changeTitle('지금 인기 있는 영화');
this.#removePreviousError();
this.#removeMoreButton();
this.#renderAllMovieList();
});
}
Expand All @@ -169,5 +242,34 @@ export default class App {

ulElement.innerHTML = '';
this.#appendMovieCard(movieDatas, ulElement as HTMLElement);
this.#observeSentinel();
}

#initEventListeners() {
const itemList = document.querySelector('ul.item-list');

if (itemList) {
itemList.addEventListener('click', this.#handleMovieCardClick.bind(this));
}
}

#handleMovieCardClick(event: any) {
const clickedElement = event.target.closest('.item-card');

if (clickedElement) {
const movieId = Number(clickedElement.dataset.movieid);
const modal = Modal.getInstance(movieId);
modal.openModal();
}
}

#goToTop() {
const topButton = document.querySelector('#goToTop');

if (topButton) {
topButton.addEventListener('click', () => {
window.scrollTo(0, 0);
});
}
}
}
1 change: 1 addition & 0 deletions src/components/ErrorRender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default class ErrorRender {
if (this.#status[0] === ERROR_2XX) this.#renderNoResult();
if (this.#status[0] === ERROR_4XX) this.#renderClientError();
if (this.#status[0] === ERROR_5XX) this.#renderServerError();
return new Error('오류');
}

#renderNoResult() {
Expand Down
Loading

0 comments on commit 22d0448

Please sign in to comment.