[VanillaJS] 고양이 검색 사이트 🐈‍⬛ - 구현편 | protect-me

🕊 프로그래머스 - [프론트엔드] 고양이 사진 검색 사이트


1. HTML, CSS 관련

1-1.

현재 HTML 코드가 전체적으로 <div> 로만 이루어져 있습니다. 이 마크업을 시맨틱한 방법으로 변경해야 합니다.

header, nav, section, article, footer

1-2.

유저가 사용하는 디바이스의 가로 길이에 따라 검색결과의 row 당 column 갯수를 적절히 변경해주어야 합니다.

  • 992px 이하: 3개, 768px 이하: 2개, 576px 이하: 1개
.SearchResult {
  margin-top: 10px;
  display: grid;
  grid-template-columns: repeat(4, minmax(250px, 1fr));
  grid-gap: 10px;
}

/* 992px 이하 3개*/
@media (max-width : 992px){
  .SearchResult{
      grid-template-columns: repeat(3, minmax(250px, 1fr));
  }
}
/* 768px 이하 적용 2개 */
@media (max-width : 768px){
  .SearchResult{
      grid-template-columns: repeat(2, minmax(250px, 1fr));
  }
}
/* 576px 이하 적용 1개 */
@media (max-width : 576px){
  .SearchResult{
      grid-template-columns: repeat(1, minmax(250px, 1fr));
  }
}

1-3.

다크 모드(Dark mode)를 지원하도록 CSS를 수정해야 합니다.
CSS 파일 내의 다크 모드 관련 주석을 제거한 뒤 구현합니다.
모든 글자 색상은 #FFFFFF , 배경 색상은 #000000 로 한정합니다.
기본적으로는 OS의 다크모드의 활성화 여부를 기반으로 동작하게 하되, 유저가 테마를 토글링 할 수 있도록 좌측 상단에 해당 기능을 토글하는 체크박스를 만듭니다.

CSS 미디어 쿼리 prefers-color-scheme (다크 모드)
웹페이지 다크 모드 지원하기

App.js

import SearchInput from "./SearchInput.js";
import SearchResult from "./SearchResult.js";
import ImageInfo from "./ImageInfo.js";
import DarkmodeToggle from "./DarkmodeToggle.js";
import api from "./api.js";

console.log("app is running!");
export default class App {
  $target = null;
  data = [];

  constructor($target) {
    this.$target = $target;

    this.darkmodeToggle = new DarkmodeToggle($target)
    
    //...

DarkmodeToggle.js

export default class DarkmodeToggle {
  constructor($target) {
    const darkModeWrapper = document.createElement("div");
    darkModeWrapper.className = "dark-mode-wrapper";
    this.darkModeWrapper = darkModeWrapper;

    this.currentMode = localStorage.getItem('theme')
      || (
        window.matchMedia("(prefers-color-scheme: dark)").matches
          ? "dark"
          : "light"
      )
    document.documentElement.setAttribute('data-theme', this.currentMode);

    $target.appendChild(darkModeWrapper);
    this.render();
  }

  toggleMode() {
    this.currentMode = this.currentMode === "dark" ? "light" : "dark"
    const darkModeBtn = document.querySelector('.dark-mode-btn')
    darkModeBtn.innerText = this.currentMode == "dark" ? "🌕" : "🌑"
    document.documentElement.setAttribute('data-theme', this.currentMode);
    localStorage.setItem('theme', this.currentMode);
  }

  render() {
    const darkModeBtn = document.createElement("span");
    darkModeBtn.className = "dark-mode-btn";
    darkModeBtn.innerText = this.currentMode == "dark" ? "🌕" : "🌑"
    darkModeBtn.addEventListener("click", this.toggleMode)
    this.darkModeWrapper.appendChild(darkModeBtn);
  }
}

style.css

:root {
  --background: #fff;
  --text: #000;
}

@media (prefers-color-scheme: dark) {
  :root {
    --background: #000;
    --text: #fff;
  }
}

[data-theme="light"] {
  --background: #fff;
  --text: #000;
}

[data-theme="dark"] {
  --background: #000;
  --text: #fff;
}

body {
  background-color: var(--background);
  color: var(--text);
  transition: background 500ms ease-in-out, color 200ms ease;
}

.dark-mode-btn {
  font-size: 30px;
}

2. 이미지 상세 보기 모달 관련

2-1.

디바이스 가로 길이가 768px 이하인 경우, 모달의 가로 길이를 디바이스 가로 길이만큼 늘려야 합니다.

style.css

@media (max-width : 576px){
  .SearchResult{
      grid-template-columns: repeat(1, minmax(250px, 1fr));
  }
  .ImageInfo .content-wrapper {
    width: 100vw;
  }
}

2-2.

필수 이미지를 검색한 후 결과로 주어진 이미지를 클릭하면 모달이 뜨는데, 모달 영역 밖을 누르거나 / 키보드의 ESC 키를 누르거나 / 모달 우측의 닫기(x) 버튼을 누르면 닫히도록 수정해야 합니다.

ImageInfo.js

export default class ImageInfo {
  $imageInfo = null;
  data = null;

  constructor({ $target, data }) {
    const $imageInfo = document.createElement("div");
    $imageInfo.className = "ImageInfo";
    this.$imageInfo = $imageInfo;
    $target.appendChild($imageInfo);
    document.addEventListener("keydown", (e) => { // ESC 버튼 클릭 시 닫기
      e.key === "Escape" ? this.closeImageInfo() : ''
    });

    this.data = data;
    this.render();
  }

  setState(nextData) {
    this.data = nextData;
    this.render();
  }

  closeImageInfo() {
    this.data.visible = false
    this.$imageInfo.style.display = "none";
    const $imageInfo = document.querySelector('.ImageInfo')
    $imageInfo.innerHTML = null
  }

  render() {
    if (this.data.visible) {
      const { name, url, temperament, origin } = this.data.image;

      const contentWrapper = document.createElement('div')
      contentWrapper.className = 'content-wrapper'

      const title = document.createElement('div')
      title.className = 'title'
      const titleText = document.createElement('span')
      titleText.innerText = name
      const closeButton = document.createElement('div')
      closeButton.className = 'close'
      closeButton.innerText = '❌'

      const image = document.createElement('img')
      image.setAttribute('src', url)
      image.setAttribute('alt', name)

      const description = document.createElement('div')
      const temperamentText = document.createElement('div')
      temperamentText.innerText = temperament
      const originText = document.createElement('div')
      originText.innerText = origin

      title.append(titleText, closeButton)
      description.append(temperamentText, originText)

      contentWrapper.append(title, image, description)
      this.$imageInfo.append(contentWrapper)

      this.$imageInfo.addEventListener('click', (event) => {
        if (event.target.className === 'ImageInfo'
          || event.target.className === 'close') {
          this.closeImageInfo()
        }
      })

      this.$imageInfo.style.display = "block";
    } else {
      this.$imageInfo.style.display = "none";
    }
  }
}

2-3.

모달에서 고양이의 성격, 태생 정보를 렌더링합니다. 해당 정보는 /cats/:id 를 통해 불러와야 합니다.

api.js

import 'regenerator-runtime/runtime'

const API_ENDPOINT =
  "https://oivhcpn8r9.execute-api.ap-northeast-2.amazonaws.com/dev";

// const api = {
//   fetchCats: keyword => {
//     return fetch(`${API_ENDPOINT}/api/cats/search?q=${keyword}`).then(res =>
//       res.json()
//     );
//   },
// };


const request = async (url) => {
  try {
    const res = await fetch(url);
    if (res.ok) {
      const data = await res.json();
      return data;
    } else {
      const errData = await res.json();
      throw errData;
    }
  } catch (e) {
    throw {
      message: e.message,
      status: e.status,
    };
  }
};

const api = {
  fetchCats: async (keyword) => {
    try {
      const result = await request(
        `${API_ENDPOINT}/api/cats/search?q=${keyword}`
      );
      return result
    } catch (e) {
      return e;
    }
  },
  fetchDetailInfo: async (id) => {
    try {
      const result = await request(`${API_ENDPOINT}/api/cats/${id}`);
      return result
    } catch (e) {
      return e;
    }
  },
}

export default api;

ImageInfo.js

import api from "./api.js";

(...)

  async render() {
    if (this.data.visible) {
      const { name, url, temperament, origin } = this.data.image;
      const detailInfo = await api.fetchDetailInfo(this.data.image.id);
      console.log('here', detailInfo);


      const contentWrapper = document.createElement('div')
      contentWrapper.className = 'content-wrapper'

      const title = document.createElement('div')
      title.className = 'title'
      const titleText = document.createElement('span')
      titleText.innerText = name
      const closeButton = document.createElement('div')
      closeButton.className = 'close'
      closeButton.innerText = '❌'

      const image = document.createElement('img')
      image.setAttribute('src', url)
      image.setAttribute('alt', name)

      const description = document.createElement('div')
      const temperamentText = document.createElement('div')
      temperamentText.innerText = `성격 : ${detailInfo.data.temperament}`
      const originText = document.createElement('div')
      originText.innerText = `태생 : ${detailInfo.data.origin}`

      (...)

2-4.

추가 모달 열고 닫기에 fade in/out을 적용해 주세요.

ImageInfo.js

  (...)
 
  closeImageInfo() {
    this.$imageInfo.removeAttribute('data-status')
    this.$imageInfo.setAttribute('data-status', 'hidden');
    setTimeout(() => {
      this.$imageInfo.removeAttribute('data-status');
      this.$imageInfo.setAttribute('data-status', 'removed');
      this.$imageInfo.innerHTML = null
      this.data.visible = false
      this.$imageInfo.style.display = "none";
    }, 1000);
  }

  async render() {
    console.log('render');
    if (this.data.visible) {
      this.$imageInfo.setAttribute('data-status', 'shown')
  
  (...)

style.css

참고(js fadein fadeout trigger)

.ImageInfo {
  position: fixed;
  left: 0;
  top: 0;
  width: 100vw;
  height: 100vh;
  background-color: rgba(0, 0, 0, 0.5);
}

/* REF: https://stackoverflow.com/questions/19466670/trigger-css-animation-fade-in-fade-out-with-javascript-only-works-in-one-dir */

@keyframes fade {
  0% { opacity: 0; }
  100% { opacity: 1; display: block; }
}

.ImageInfo[data-status="shown"] {
  opacity: 1;
  animation: fade 1s;
}

.ImageInfo[data-status="hidden"] {
  opacity: 0;
  animation: fade 2s;
  animation-direction: reverse;
}

.ImageInfo[data-status="removed"] {
  display: none;
}

3. 검색 페이지 관련

4. 스크롤 페이징 구현

5. 랜덤 고양이 배너 섹션 추가

6. 코드 구조 관련


Photo by Hannah Troupe on Unsplash

좋은 웹페이지 즐겨찾기