Hi, I’m Kang Byeong-hyeon.

..

[카카오 테크 캠퍼스 2기] 1단계 8주차 WIL - API를 활용한 영화 검색 사이트




1. 사용자 지정 CSS

색상 변수 정의

CSS에서 사용할 색상 변수를 정의한다.

색상 변수를 사용하면 일관된 색상 테마를 쉽게 적용하고 관리할 수 있다.

html {
  --color-black: #0e111b;
  --color-white: #fff;
  --color-white-50: rgba(255, 255, 255, 0.5);
  --color-white-30: rgba(255, 255, 255, 0.3);
  --color-white-20: rgba(255, 255, 255, 0.2);
  --color-white-10: rgba(255, 255, 255, 0.1);
  --color-white-5: rgba(255, 255, 255, 0.05);
  --color-primary: #fdc000;
  --color-hover: #f86a05;
  --color-area: #1c212e;
}

색상 변수 사용

정의한 색상 변수를 실제 var()를 통해 CSS에 적용하여 사이트의 기본 스타일을 설정한다.

body {
  font-family: "Roboto", sans-serif;
  line-height: 1.4;
  background-color: var(--color-black);
  color: var(--color-white);
}

2. Headline 컴포넌트

컴포넌트 구현

영화 검색 사이트의 주요 헤드라인을 표시하는 Headline 컴포넌트를 구현한다.

import { Component } from "../core";

export default class Headline extends Component {
  render() {
    this.el.classList.add("headline");
    this.el.innerHTML = `
      <h1>
        <span>OMDb API</span><br />
        THE OPEN <br />
        MOVIE DATABASE
      </h1>
      <p>
        The OMDb API is a RESTful web service to obtain movie information,
        all content and images on the site are contributed and maintained by our users.<br />
        If you find this service useful, please consider making a one-time donation or become a patron.
      </p>
    `;
  }
}

스타일링

헤드라인 컴포넌트에 이전에 변수로 지정한 CSS 속성 값을 사용하여 스타일링한다.

.headline {
  margin-bottom: 40px;
}

.headline h1 {
  font-family: "Oswald", sans-serif;
  font-size: 80px;
  line-height: 1;
  margin-bottom: 40px;
}

.headline h1 span {
  color: var(--color-primary);
}

.headline p {
  color: var(--color-white-30);
}




3. Home 컴포넌트

컴포넌트 구현

Home 컴포넌트는 메인 페이지를 구성하며, Headline 컴포넌트를 포함하여 전체적인 레이아웃을 설정한다.

import Headline from "../components/Headline";
import { Component } from "../core";

export default class Home extends Component {
  render() {
    const headline = new Headline().el;
    this.el.classList.add("container");
    this.el.append(headline);
  }
}

결과물

스크린샷 2024-05-26 오후 2.34.02.png




4. Search 컴포넌트

Search 컴포넌트는 사용자가 영화 제목을 입력하고 검색할 수 있도록 한다.

movieStore를 통해 상태를 관리하고, searchMovies 함수를 호출하여 영화 데이터를 가져온다.

컴포넌트 구현

import { Component } from "../core";
import movieStore, { searchMovies } from "../store/movie";

export default class Search extends Component {
  render() {
    this.el.classList.add("search");
    this.el.innerHTML = `
      <input type="text" placeholder="Enter the movie title to search!"/>
      <button type="button" class="btn btn--primary">Search!</button>
    `;

    const inputEl = this.el.querySelector("input");
    inputEl.addEventListener("input", () => {
      movieStore.state.searchText = inputEl.value;
    });

    inputEl.addEventListener("keydown", (e) => {
      if (e.key === "Enter" && movieStore.state.searchText.trim())
        searchMovies(1);
    });

    const btnEl = this.el.querySelector("button");
    btnEl.addEventListener("click", () => {
      if (movieStore.state.searchText.trim()) searchMovies(1);
    });
  }
}

버튼 공통 스타일링

버튼의 일관된 스타일을 유지하기 위해 버튼의 공통 스타일을 정의하고, 컴포넌트 내의 검색 입력 필드에 대한 스타일링을 정의한다.

.btn {
  height: 50px;
  padding: 0 20px;
  border: none;
  border-radius: 4px;
  outline: none;
  font-size: 14px;
  font-weight: 700;
  color: var(--color-white);
  background-color: var(--color-area);
  cursor: pointer;
  transition: 0.3s;
}

.btn:hover {
  color: var(--color-hover);
}

.btn--primary {
  background-color: var(--color-primary);
  color: var(--color-black);
}

.btn--primary:hover {
  background-color: var(--color-hover);
  color: var(--color-white);
}
.search {
  display: flex;
  gap: 10px;
  margin-bottom: 30px;
}

.search input {
  flex-grow: 1;
  height: 50px;
  padding: 0 20px;
  border-radius: 4px;
  border: none;
  outline: none;
  font-size: 14px;
  color: var(--color-white);
  background-color: var(--color-area);
}

.search input::placeholder {
  color: var(--color-white-30);
}

.search .btn {
  flex-grow: 1;
  max-width: 150px;
}

Movie Store 구현

movieStore는 상태 관리와 API 호출을 담당하고, searchMovies 함수는 OMDb API를 호출하여 검색 결과를 가져온다.

import { Store } from "../core";

const store = new Store({
  searchText: "",
  page: 1,
  movies: [],
});

export default store;

export const searchMovies = async (page) => {
  const res = await fetch(
    `https://omdbapi.com?apikey=7035c60c&s=${store.state.searchText}&page=${page}`
  );
  const json = await res.json();
  console.log(json);
};

결과물

스크린샷 2024-05-26 오후 3.41.27.png




5. MovieList 컴포넌트

MovieList 컴포넌트는 movieStoremovies 상태를 구독하여, 상태가 변경될 때마다 검색 결과를 새로 렌더링한다.

컴포넌트 구현

import { Component } from "../core";
import movieStore from "../store/movie";

export default class MovieList extends Component {
  constructor() {
    super();
    movieStore.subscribe("movies", () => {
      this.render();
    });
  }

  render() {
    this.el.classList.add("movie-list");
    this.el.innerHTML = `
      <div class="movies"></div>
    `;

    const moviesEl = this.el.querySelector(".movies");
    moviesEl.append(
      ...movieStore.state.movies.map((movie) => {
        const movieEl = document.createElement("div");
        movieEl.classList.add("movie");
        movieEl.textContent = movie.Title;
        return movieEl;
      })
    );
  }
}

Home 컴포넌트 업데이트

Home 컴포넌트를 업데이트하여 Headline, Search, MovieList 컴포넌트를 포함하도록 한다.

import Headline from "../components/Headline";
import MovieList from "../components/MovieList";
import Search from "../components/Search";
import { Component } from "../core";

export default class Home extends Component {
  render() {
    const headline = new Headline().el;
    const search = new Search().el;
    const movieList = new MovieList().el;

    this.el.classList.add("container");
    this.el.append(headline, search, movieList);
  }
}

결과물

스크린샷 2024-05-26 오후 3.53.58.png




6. MovieItem 컴포넌트

MovieItem 컴포넌트는 영화 정보를 받아와서 영화 포스터와 제목, 년도를 카드 형태로 표시한다.

컴포넌트 구현

import { Component } from "../core";

export default class MovieItem extends Component {
  constructor(props) {
    super({ props, tagName: "a" });
  }

  render() {
    const { movie } = this.props;

    this.el.setAttribute("href", `#/movie?id=${movie.imdbID}`);
    this.el.classList.add("movie");
    this.el.style.backgroundImage = `url(${movie.Poster})`;
    this.el.innerHTML = `
      <div class="info">
        <div class="year">
          ${movie.Year}
        </div>
        <div class="title">
          ${movie.Title}
        </div>
      </div>
    `;
  }
}

MovieList 컴포넌트 업데이트

MovieList 컴포넌트를 업데이트하여, 각 영화를 MovieItem 컴포넌트로 표시하도록 한다.

import { Component } from "../core";
import movieStore from "../store/movie";
import MovieItem from "./MovieItem";

export default class MovieList extends Component {
  constructor() {
    super();
    movieStore.subscribe("movies", () => {
      this.render();
    });
  }

  render() {
    this.el.classList.add("movie-list");
    this.el.innerHTML = `
      <div class="movies"></div>
    `;

    const moviesEl = this.el.querySelector(".movies");
    moviesEl.append(
      ...movieStore.state.movies.map((movie) => new MovieItem({ movie }).el)
    );
  }
}

스타일링

.movie-list {
  padding: 20px;
  border-radius: 4px;
  background-color: var(--color-area);
}

.movie-list .movies {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 20px;
}

.movies .movie {
  --width: 200px;
  width: var(--width);
  height: calc(var(--width) * 3 / 2);
  border-radius: 4px;
  background-color: var(--color-black);
  background-size: cover;
  overflow: hidden;
  position: relative;
}

.movies .movie:hover::after {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  border: 6px solid var(--color-primary);
}

.movies .movie .info {
  width: 100%;
  padding: 14px;
  box-sizing: border-box;
  font-size: 14px;
  text-align: center;
  position: absolute;
  left: 0;
  bottom: 0;
  background-color: rgba(14, 17, 27, 0.3);
  backdrop-filter: blur(10px);
}

.movies .movie .info .year {
  color: var(--color-primary);
}

.movies .movie .info .title {
  color: var(--color-white);
}

결과물

May-26-2024 16-21-00.gif




7. MovieListMore 컴포넌트

MovieListMore 컴포넌트는 사용자가 영화 목록을 스크롤하면서 끝에 도달했을 때 추가적인 영화 목록을 불러올 수 있도록 한다.

Store 및 영화 검색 함수 구현

영화 관련 상태 및 기능을 관리하기 위해 storesearchMovies 함수를 구현한다.

storepageMax(최대 페이지 수) 상태를 추가하고, searchMovies 함수는 주어진 페이지의 영화 목록을 검색하고 상태를 업데이트하도록 한다.

import { Store } from "../core";

const store = new Store({
  searchText: "",
  page: 1,
  pageMax: 1,
  movies: [],
});

export default store;

export const searchMovies = async (page) => {
  store.state.page = page;
  if (page === 1) {
    store.state.movies = [];
  }

  const res = await fetch(
    `https://omdbapi.com?apikey=7035c60c&s=${store.state.searchText}&page=${page}`
  );
  const { Search, totalResults } = await res.json();
  store.state.movies = [...store.state.movies, ...Search];
  store.state.pageMax = Math.ceil(Number(totalResults) / 10);
};

컴포넌트 구현

import { Component } from "../core";
import movieStore, { searchMovies } from "../store/movie";

export default class MovieListMore extends Component {
  constructor() {
    super({ tagName: "button" });
    movieStore.subscribe("pageMax", () => {
      const { page, pageMax } = movieStore.state;
      pageMax > page
        ? this.el.classList.remove("hide")
        : this.el.classList.add("hide");
    });
  }

  render() {
    this.el.classList.add("btn", "view-more", "hide");
    this.el.textContent = "View more..";
    this.el.addEventListener("click", async () => {
      await searchMovies(movieStore.state.page + 1);
    });
  }
}

스타일링

.view-more {
  width: 100%;
  max-width: 300px;
  margin: 20px auto;
  display: block;
}

.view-more.hide {
  display: none;
}

Home 컴포넌트 업데이트

Home 컴포넌트를 업데이트하여 MovieListMore 컴포넌트를 포함하도록 한다.

import Headline from "../components/Headline";
import MovieList from "../components/MovieList";
import MovieListMore from "../components/MovieListMore";
import Search from "../components/Search";
import { Component } from "../core";

export default class Home extends Component {
  render() {
    const headline = new Headline().el;
    const search = new Search().el;
    const movieList = new MovieList().el;
    const movieListMore = new MovieListMore().el;

    this.el.classList.add("container");
    this.el.append(headline, search, movieList, movieListMore);
  }
}

결과물

May-26-2024 17-14-35.gif




8. 로딩 애니메이션 추가 (loader / spinner)

영화 목록을 불러오는 동안 로딩을 시각적으로 표시하기 위해 loader 또는 spinner를 추가한다. 이를 통해 사용자는 작업이 진행 중임을 알 수 있다.

스타일링

.the-loader {
  width: 30px;
  height: 30px;
  margin: 30px auto;
  border: 4px solid var(--color-primary); /* 테두리 색상 설정 */
  border-top-color: transparent; /* 상단 테두리를 투명하게 만들어 회전 효과를 줍니다. */
  border-radius: 50%; /* 원형 로더를 만들기 위해 원형 모양으로 설정 */
  animation: loader 1s infinite linear; /* 회전 애니메이션 적용 */
}

.the-loader.hide {
  display: none; /* 로딩이 완료되면 로더를 숨깁니다. */
}

@keyframes loader {
  0% {
    transform: rotate(0deg); /* 회전 애니메이션 시작 각도 */
  }
  100% {
    transform: rotate(360deg); /* 회전 애니메이션 종료 각도 */
  }
}

MovieList 컴포넌트 업데이트

import { Component } from "../core";
import movieStore from "../store/movie";
import MovieItem from "./MovieItem";

export default class MovieList extends Component {
  constructor() {
    super();
    movieStore.subscribe("movies", () => {
      this.render();
    });
    movieStore.subscribe("loading", () => {
      this.render();
    });
  }

  render() {
    this.el.classList.add("movie-list");
    this.el.innerHTML = `
      <div class="movies"></div>
      <div class="the-loader hide"></div>
    `;

    const moviesEl = this.el.querySelector(".movies");
    moviesEl.append(
      ...movieStore.state.movies.map((movie) => new MovieItem({ movie }).el)
    );

    const loaderEl = this.el.querySelector(".the-loader");
    movieStore.state.loading
      ? loaderEl.classList.remove("hide")
      : loaderEl.classList.add("hide");
  }
}

Store 및 검색 함수 업데이트

import { Store } from "../core";

const store = new Store({
  searchText: "",
  page: 1,
  pageMax: 1,
  movies: [],
  loading: false,
});

export default store;

export const searchMovies = async (page) => {
  store.state.loading = true;
  store.state.page = page;
  if (page === 1) {
    store.state.movies = [];
  }

  const res = await fetch(
    `https://omdbapi.com?apikey=7035c60c&s=${store.state.searchText}&page=${page}`
  );
  const { Search, totalResults } = await res.json();
  store.state.movies = [...store.state.movies, ...Search];
  store.state.pageMax = Math.ceil(Number(totalResults) / 10);
  store.state.loading = false;
};

결과물

May-26-2024 17-37-02.gif




9. 예외 처리와 메시지 출력

API 호출 중 발생할 수 있는 오류를 처리하여 사용자에게 명확한 피드백을 제공하도록 한다.

Store와 검색 함수 업데이트

storesearchMovies 함수를 업데이트하여 예외 처리와 메시지 상태를 추가한다.

import { Store } from "../core";

const store = new Store({
  searchText: "",
  page: 1,
  pageMax: 1,
  movies: [],
  loading: false,
  message: "Search for the movie title!",
});

export default store;

export const searchMovies = async (page) => {
  store.state.loading = true;
  store.state.page = page;
  if (page === 1) {
    store.state.movies = [];
    store.state.message = "";
  }

  try {
    const res = await fetch(
      `https://omdbapi.com?apikey=7035c60c&s=${store.state.searchText}&page=${page}`
    );
    const { Search, totalResults, Response, Error } = await res.json();

    if (Response === "True") {
      store.state.movies = [...store.state.movies, ...Search];
      store.state.pageMax = Math.ceil(Number(totalResults) / 10);
    } else {
      store.state.message = Error;
    }
  } catch (err) {
    console.error("searchMovies error:", err);
    store.state.message = "An error occurred while searching for movies.";
  } finally {
    store.state.loading = false;
  }
};

MovieList 컴포넌트 업데이트

MovieList 컴포넌트를 업데이트하여 메시지를 표시한다.

import { Component } from "../core";
import movieStore from "../store/movie";
import MovieItem from "./MovieItem";

export default class MovieList extends Component {
  constructor() {
    super();
    movieStore.subscribe("movies", () => this.render());
    movieStore.subscribe("loading", () => this.render());
    movieStore.subscribe("message", () => this.render());
  }

  render() {
    this.el.classList.add("movie-list");
    this.el.innerHTML = `
      ${
        movieStore.state.message
          ? `<div class="message">${movieStore.state.message}</div>`
          : `<div class="movies"></div>`
      }
      <div class="the-loader hide"></div>
    `;

    const moviesEl = this.el.querySelector(".movies");
    moviesEl?.append(
      ...movieStore.state.movies.map((movie) => new MovieItem({ movie }).el)
    );

    const loaderEl = this.el.querySelector(".the-loader");
    movieStore.state.loading
      ? loaderEl.classList.remove("hide")
      : loaderEl.classList.add("hide");
  }
}

스타일링

.movie-list .message {
  color: var(--color-primary);
  font-size: 20px;
  text-align: center;
}

결과물

May-26-2024 18-27-22.gif