-
Notifications
You must be signed in to change notification settings - Fork 155
[2단계 - 영화 목록 불러오기] 콘티 미션 제출합니다. #293
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: iftype
Are you sure you want to change the base?
Changes from 13 commits
1b4f372
7a9e41d
a3960c0
0e15c68
3347c6d
4aebacb
c52cd17
f358ea2
9ed6647
934b327
7b9e255
60cf7d4
cc3739f
4606ee1
ddbc854
a5f0828
d9f25ba
1c0f52c
1a298a0
07401a9
9ea392a
32a09d4
cd7cda0
34f4bec
8600412
473fb51
05c9606
80c0f9b
f7b6367
be28652
39db170
332e839
5ea837e
03970e1
532d0b4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
| @@ -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,14 +11,15 @@ const options = { | |||
| }, | ||||
| }; | ||||
|
|
||||
| const fetchAPI = async (req: Request): Promise<ResponseMovie> => { | ||||
| const fetchAPI = async <T extends ResponseMovie | MovieDetail>(req: Request): Promise<T> => { | ||||
| 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(); | ||||
| console.log(resultUrl); | ||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 디버깅용 console.log 제거 필요 프로덕션 코드에 디버깅용 🔧 제안하는 수정- console.log(resultUrl);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||
|
|
||||
| const response = await fetch(resultUrl, options); | ||||
| const data = await response.json(); | ||||
|
|
@@ -43,3 +44,10 @@ export const fetchPopularMovies = (page: number = 1): Promise<ResponseMovie> => | |||
| params: { page }, | ||||
| }); | ||||
| }; | ||||
|
|
||||
| export const fetchMovieDetails = (movie_id: number): Promise<MovieDetail> => { | ||||
| return fetchAPI({ | ||||
| path: PATH.MOVIE_DETAIL(movie_id), | ||||
| params: {}, | ||||
| }); | ||||
| }; | ||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,18 +1,32 @@ | ||
| 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; | ||
| }; | ||
|
Comment on lines
+12
to
+20
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: In TMDB’s Movie Details response ( Sources: Citations:
TMDB API 검증 결과에 따르면 현재 코드에서:
두 타입이 불일치합니다. 다음 질문을 고려하며 해결 방법을 생각해 보세요:
🤖 Prompt for AI Agents |
||
|
|
||
| export type ResponseMovie = { | ||
| results: MovieData[]; | ||
| page: number; | ||
| total_pages: number; | ||
| }; | ||
|
|
||
| type Params = { | ||
| page: number; | ||
| page?: number; | ||
| query?: string | undefined; | ||
| language?: string; | ||
| region?: string; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,18 +1,18 @@ | ||
| import { MovieData } from '../../api/types.ts'; | ||
| import { $ } from '../../utils/dom.ts'; | ||
| import { Error } from './Error.ts'; | ||
| import { MoreButton } from './MoreButton.ts'; | ||
| import { MovieItem } from './MovieItem.ts'; | ||
| import { MovieItemSkeleton } from './MovieItemSkeleton.ts'; | ||
| import { NothingResult } from './NothingResult.ts'; | ||
|
|
||
| export default class Main { | ||
| #$element: HTMLElement; | ||
| #$list: HTMLElement; | ||
| #$skeletons: HTMLElement[] = []; | ||
| #$moreButton: HTMLElement | null; | ||
| #$skeletons: Map<string, HTMLElement[]>; | ||
|
|
||
| constructor(title: string, onDetail: (movie_id: number) => void) { | ||
| this.#$skeletons = new Map<string, HTMLElement[]>(); | ||
|
|
||
| constructor(title: string) { | ||
| this.#$element = document.createElement('div'); | ||
| this.#$element.className = 'container'; | ||
| this.#$element.innerHTML = ` | ||
|
|
@@ -24,38 +24,41 @@ export default class Main { | |
| </main> | ||
| `; | ||
| this.#$list = $<HTMLElement>(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)); | ||
| }); | ||
|
Comment on lines
+26
to
+31
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 클릭 이벤트 위임 시 유효성 검사가 필요합니다. 현재 구현에서 이 경우를 어떻게 처리하면 좋을지 생각해 보시겠어요? 💡 힌트 $ul.addEventListener('click', (e) => {
const $li = (e.target as Element).closest('li');
+ if (!$li) return;
const id = $li?.dataset.id;
onDetail(Number(id));
});🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| get $element() { | ||
| return this.#$element; | ||
| } | ||
|
|
||
| renderMovies(movies: MovieData[]) { | ||
| this.removeSkeletons(); | ||
| renderMovies(movies: MovieData[], page: number) { | ||
| this.removeSkeletons(page); | ||
| const $fragment = new DocumentFragment(); | ||
| movies.forEach((movie) => $fragment.append(MovieItem(movie))); | ||
| this.#$list.append($fragment); | ||
| } | ||
|
|
||
| renderSkeletons(length: number = 20) { | ||
| this.#$skeletons = Array.from({ length }, () => MovieItemSkeleton()); | ||
| this.#$skeletons.forEach(($skeleton) => this.#$list.append($skeleton)); | ||
| } | ||
|
|
||
| removeSkeletons() { | ||
| this.#$skeletons.forEach(($skeleton) => $skeleton.remove()); | ||
| this.#$skeletons = []; | ||
| } | ||
|
|
||
| renderMoreButton(onClick: () => void) { | ||
| this.#$moreButton = MoreButton(onClick); | ||
| $<HTMLElement>(this.#$element, 'section').append(this.#$moreButton); | ||
| renderSkeletons(page: number, length: number = 20) { | ||
| if (this.#$skeletons.has(String(page))) { | ||
| this.removeSkeletons(page); | ||
| } | ||
| const $newSkeletons = Array.from({ length }, () => MovieItemSkeleton()); | ||
| $newSkeletons.forEach(($skeleton) => this.#$list.append($skeleton)); | ||
| this.#$skeletons.set(String(page), $newSkeletons); | ||
| } | ||
|
|
||
| removeMoreButton() { | ||
| this.#$moreButton?.remove(); | ||
| this.#$moreButton = null; | ||
| removeSkeletons(page: number) { | ||
| if (!this.#$skeletons.has(String(page))) { | ||
| return; | ||
| } | ||
| const $skeletonList = this.#$skeletons.get(String(page)); | ||
| $skeletonList?.forEach(($skeleton) => $skeleton.remove()); | ||
| } | ||
|
|
||
| renderError(messsage: string) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| // }; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,94 @@ | ||||||||||||||||||||
| import { getOriginalImageUrl } from '../../api/renderImage'; | ||||||||||||||||||||
| import { MovieDetail } from '../../api/types'; | ||||||||||||||||||||
| import LocalStorage from '../../storage/LocalStorage'; | ||||||||||||||||||||
| import { $ } from '../../utils/dom'; | ||||||||||||||||||||
| import { Star } from '../common/Star'; | ||||||||||||||||||||
| import SubmitRate from './SubmitRate'; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| export default class Modal { | ||||||||||||||||||||
| #$modal: HTMLElement; | ||||||||||||||||||||
| #$body: HTMLElement; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| constructor($body: HTMLElement) { | ||||||||||||||||||||
| this.#$body = $body; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| this.#$modal = document.createElement('div'); | ||||||||||||||||||||
| this.#$modal.id = 'modalBackground'; | ||||||||||||||||||||
| this.#$modal.className = 'modal-background'; | ||||||||||||||||||||
| this.#$modal.innerHTML = /*html */ ` | ||||||||||||||||||||
| <div class="modal"> | ||||||||||||||||||||
| <button class="close-modal" id="closeModal"> | ||||||||||||||||||||
| <img src="./images/modal_button_close.png" /> | ||||||||||||||||||||
| </button> | ||||||||||||||||||||
| <div class="modal-container"> | ||||||||||||||||||||
| <div class="modal-image"><img /></div> | ||||||||||||||||||||
| <div class="modal-description"> | ||||||||||||||||||||
| <h2></h2> | ||||||||||||||||||||
| <p class="category"></p> | ||||||||||||||||||||
| <div class="rate"></div> | ||||||||||||||||||||
| <hr /> | ||||||||||||||||||||
| <div class="modal-submit-star"> | ||||||||||||||||||||
| <h3>내 별점</h3> | ||||||||||||||||||||
| </div> | ||||||||||||||||||||
| <hr /> | ||||||||||||||||||||
| <div> | ||||||||||||||||||||
| <h3>줄거리</h3> | ||||||||||||||||||||
| <p class="detail"></p> | ||||||||||||||||||||
| </div> | ||||||||||||||||||||
| </div> | ||||||||||||||||||||
| </div> | ||||||||||||||||||||
| </div> | ||||||||||||||||||||
| `; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| $(this.#$modal, 'button').addEventListener('click', () => this.close()); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| get $element() { | ||||||||||||||||||||
| return this.#$modal; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| #update(movie: MovieDetail) { | ||||||||||||||||||||
| const { title, release_date, overview, poster_path, genres, vote_average } = movie; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| $<HTMLImageElement>(this.#$modal, '.modal-image img').src = getOriginalImageUrl(poster_path); | ||||||||||||||||||||
| $(this.#$modal, 'h2').textContent = title; | ||||||||||||||||||||
| $(this.#$modal, '.detail').textContent = overview; | ||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. poster_path가 null일 경우를 고려해 보세요. TMDB API에서 일부 영화는 기본 이미지(placeholder)를 표시하는 방법을 고려해 보시겠어요? 🤖 Prompt for AI Agents |
||||||||||||||||||||
|
|
||||||||||||||||||||
| 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); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
+61
to
+83
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 모달을 닫고 다른 영화를 열었을 때 새 이미지가 로드되기 전까지 이전 이미지가 보이는 이유가 요 메서드 동작 때문일 것 같군요?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 지금 보니 여기도 기본 이미지나 스켈레톤을 달 수 있겠네요. 수정하겠습니다 |
||||||||||||||||||||
|
|
||||||||||||||||||||
| open(movie: MovieDetail) { | ||||||||||||||||||||
| this.#$body.className = 'modal-open'; | ||||||||||||||||||||
| this.#$modal.classList.add('active'); | ||||||||||||||||||||
| this.#update(movie); | ||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. body의 기존 클래스가 덮어씌워질 수 있습니다.
🔧 수정 제안 open(movie: MovieDetail) {
- this.#$body.className = 'modal-open';
+ this.#$body.classList.add('modal-open');
this.#$modal.classList.add('active');📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
|
|
||||||||||||||||||||
| const { id } = movie; | ||||||||||||||||||||
| const movieRate = Number(LocalStorage.getRate(`${id}`)) || 0; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const $submitRate = new SubmitRate(movieRate, (rate) => { | ||||||||||||||||||||
| LocalStorage.saveRate(`${id}`, String(rate)); | ||||||||||||||||||||
| }).$element; | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const $container = $(this.#$modal, '.modal-submit-star'); | ||||||||||||||||||||
| const $oldCon = $container.querySelector('.submit-rate-container'); | ||||||||||||||||||||
| if ($oldCon) $oldCon.remove(); | ||||||||||||||||||||
| $container.append($submitRate); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| close() { | ||||||||||||||||||||
| console.log('object'); | ||||||||||||||||||||
| this.#$body.classList.remove('modal-open'); | ||||||||||||||||||||
| this.#$modal.classList.remove('active'); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
+107
to
+110
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 디버그용 console.log 구문을 제거해 주세요.
🧹 정리 제안 close() {
- console.log('object');
this.#$body.classList.remove('modal-open');
this.#$modal.classList.remove('active');
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| } | ||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: woowacourse/javascript-movie-review
Length of output: 315
🏁 Script executed:
Repository: woowacourse/javascript-movie-review
Length of output: 2455
🏁 Script executed:
Repository: woowacourse/javascript-movie-review
Length of output: 873
🏁 Script executed:
Repository: woowacourse/javascript-movie-review
Length of output: 2431
스크롤 기반 테스트의 구조는 적절합니다.
cy.scrollTo('bottom')은 네이티브 스크롤 이벤트를 발생시키고,src/main.ts의 스크롤 리스너가 이를 감지하여scrollEvent()를 호출합니다.src/utils/event.ts에서 300ms 쓰로틀을 거쳐CUSTOM_EVENT.SCROOL_END를 디스패치하고, 페이지가 이 이벤트를 수신하여 API 요청을 트리거합니다.cy.wait('@getNextPopularMovies')는 기본값으로 5초 동안 대기하므로, 300ms 쓰로틀 딜레이는 충분히 처리됩니다.현재 테스트 구조는 단일 스크롤 액션이므로 타이밍 문제 없이 작동해야 합니다. 다만, CI 환경에서 실제로 안정적으로 동작하는지 여러 번 실행해 확인하면 좋습니다.
🤖 Prompt for AI Agents